17 Commits
v1.0.4 ... main

Author SHA1 Message Date
domrichardson
503d2415e6 feat: associated task status with task list not space
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m52s
2026-04-01 14:29:15 +01:00
domrichardson
74d8899eec feat: Updates to dashboard and delete confirmations
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 34s
2026-04-01 13:40:18 +01:00
domrichardson
295e03feb4 fix: removed hardcoded api url
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s
2026-03-30 10:58:36 +01:00
domrichardson
b09137eca5 feat: Added the ability to delete task lists
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m48s
2026-03-30 10:14:07 +01:00
domrichardson
b9ca845b9c feat: Created task lists that work in categories
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s
2026-03-29 16:14:23 +01:00
domrichardson
a1dd2f2c00 feat: Updated styling into seperate css files 2026-03-29 15:28:44 +01:00
domrichardson
a081bff35b fix: Fixed task status on mobile 2026-03-29 14:53:03 +01:00
domrichardson
1b336299ee feat: task system
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
2026-03-27 16:33:11 +00:00
domrichardson
d793b5ccf2 feat: Light/dark modes
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m48s
2026-03-26 17:01:34 +00:00
domrichardson
005a8f4cf0 feat: Updated admin panel providers list & modal 2026-03-26 16:27:14 +00:00
domrichardson
9cf71ab4a0 feat: added search bar and results page
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m34s
2026-03-26 12:52:09 +00:00
domrichardson
cf94697d07 feat: Added better md styling
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 58s
2026-03-26 11:41:16 +00:00
domrichardson
94f11be77c fix: Fixed redis user
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m48s
2026-03-26 10:10:07 +00:00
domrichardson
6e642da57a fix: fixes to session storage
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m27s
2026-03-26 10:06:07 +00:00
domrichardson
6774c401bf feat: updated identity providers in admin panel
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 49s
2026-03-25 15:17:48 +00:00
domrichardson
1f1fd90890 feat: Updated admin panel styles 2026-03-25 14:11:39 +00:00
domrichardson
168f5eac83 feat: file explorer
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 50s
2026-03-25 11:27:15 +00:00
113 changed files with 14179 additions and 3678 deletions

View File

@@ -10,8 +10,6 @@ JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production
ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882 ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882
FRONTEND_URL="http://localhost" FRONTEND_URL="http://localhost"
VITE_API_BASE_URL="http://localhost"
# Default Admin # Default Admin
DEFAULT_ADMIN_EMAIL=admin@notely.local DEFAULT_ADMIN_EMAIL=admin@notely.local
DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_USERNAME=admin

View File

@@ -46,8 +46,6 @@ jobs:
context: . context: .
file: ./devops/docker/Dockerfile file: ./devops/docker/Dockerfile
push: true push: true
build-args: |
VITE_API_BASE_URL=${{ secrets.VITE_API_BASE_URL }}
tags: | tags: |
${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }} ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }}

View File

@@ -1,98 +1,148 @@
# Environment Configuration # Environment Setup
Copy `.env.example` files and configure for your environment: Notely uses three different environment-file locations depending on how you run the app.
## Backend (.env) ## 1. Root `.env`
```env Use the root `.env` file when running `docker compose` from the repository root.
# MongoDB
MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin
# JWT Configuration Start from:
JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters
JWT_ISSUER=noteapp ```bash
cp .env.example .env
# Encryption (32 bytes = 32 characters) ```
ENCRYPTION_KEY=00000000000000000000000000000000
### Variables Used By Docker Compose
# Server
PORT=8080 Required or commonly used:
ENV=development
LOG_LEVEL=info - `MONGODB_URI`
- `BACKEND_PORT`
# CORS (comma-separated for multiple origins) - `JWT_SECRET`
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 - `ENCRYPTION_KEY`
- `FRONTEND_URL`
# Rate Limiting - `DEFAULT_ADMIN_EMAIL`
RATE_LIMIT_REQUESTS=50 - `DEFAULT_ADMIN_USERNAME`
RATE_LIMIT_WINDOW=1s - `DEFAULT_ADMIN_PASSWORD`
``` - `NGINX_HTTP_PORT`
- `NGINX_HTTPS_PORT`
## Frontend (.env)
Optional backend runtime values that Docker Compose will also pass through if present:
```env
VITE_API_BASE_URL=http://localhost:8080 - `REDIS_ADDR`
VITE_ENV=development - `REDIS_USER`
``` - `REDIS_PASSWORD`
- `REDIS_DB`
## Development vs Production - `SESSION_TTL_HOURS`
### Development (.env.development) ### Current Defaults In The Checked-In Example
- Less strict security (for easier testing) - MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin`
- Localhost CORS allowed - Backend port: `8080`
- JWT secrets can be simple - Public frontend URL: `http://localhost`
- Logging more verbose
## 2. `backend/.env`
### Production (.env.production)
Use `backend/.env` for local backend development.
- Strict security requirements
- Specific CORS origins only Start from:
- Strong random JWT secrets
- Limited logging (performance) ```bash
- All environment variables must be set cd backend
cp .env.example .env
## Generating Secrets ```
### Variables Currently Read By The Backend Runtime
Read in `backend/cmd/server/main.go` or other active handlers:
- `MONGODB_URI`
- `JWT_SECRET`
- `ENCRYPTION_KEY`
- `PORT`
- `REDIS_ADDR`
- `REDIS_USER`
- `REDIS_PASSWORD`
- `REDIS_DB`
- `SESSION_TTL_HOURS`
- `DEFAULT_ADMIN_EMAIL`
- `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_PASSWORD`
- `FRONTEND_URL`
### Variables Present In `backend/.env.example` But Not Currently Consumed By Runtime Code
These values exist in the example file, but the current code path does not read them yet:
- `JWT_ISSUER`
- `ENV`
- `LOG_LEVEL`
- `CORS_ALLOWED_ORIGINS`
- `RATE_LIMIT_REQUESTS`
- `RATE_LIMIT_WINDOW`
### Backend Defaults If A Variable Is Missing
- `MONGODB_URI`: `mongodb://localhost:27017`
- `JWT_SECRET`: `your-secret-key-change-in-production`
- `ENCRYPTION_KEY`: `00000000000000000000000000000000`
- `PORT`: `8080`
- `REDIS_ADDR`: `localhost:6379`
- `REDIS_DB`: `0`
- `SESSION_TTL_HOURS`: `168`
- `FRONTEND_URL`: falls back to `http://localhost:5173` for login redirects
## 3. `frontend/.env`
Use `frontend/.env` for local frontend development.
Start from:
```bash
cd frontend
cp .env.example .env
```
### Frontend Variables In `frontend/.env.example`
- `VITE_ENV`
- `VITE_ENABLE_ANALYTICS`
### Variables Currently Relevant To The Frontend App
- API requests are sent to the current browser origin (same-origin runtime behavior)
The other example values are safe to keep, but the current checked-in frontend code does not actively consume them.
## Secret Generation
Examples:
```bash ```bash
# JWT Secret (32+ characters)
openssl rand -base64 32 openssl rand -base64 32
openssl rand -hex 16
# Encryption Key (32 bytes)
openssl rand -hex 16 # outputs 32 characters
# Random token
openssl rand -hex 32 openssl rand -hex 32
``` ```
## Docker Compose Use generated values for:
Environment variables are defined in `docker-compose.yml` and will override `.env` files. Update the file for your deployment: - `JWT_SECRET`
- `ENCRYPTION_KEY`
- provider secrets or other sensitive credentials stored through admin settings
```yaml ## Compose Vs Local Development
environment:
MONGODB_URI: mongodb://admin:password@mongodb:27017/noteapp?authSource=admin
JWT_SECRET: your-secret-key-change-in-production
# ... other vars
```
## Kubernetes Use the right env file for the right mode:
Use `kubectl create secret` for sensitive data: - root `.env`: Docker Compose
- `backend/.env`: local backend
- `frontend/.env`: local frontend
```bash Do not assume values from one location are automatically shared with the others.
# 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 ## Important Notes
kubectl create configmap app-config \
--from-file=config.yaml \
-n noteapp
```
--- - Do not commit real secrets
- Keep `ENCRYPTION_KEY` at 32 characters for the current AES-256 usage
**IMPORTANT**: Never commit .env files or secrets to version control! - If OAuth login is enabled, set `FRONTEND_URL` correctly so callback redirects go to the intended UI
- If Redis settings are omitted, the backend assumes a local Redis instance at `localhost:6379`

View File

@@ -14,7 +14,7 @@ This file lists the permissions currently checked by the application.
- space.edit - space.edit
- Global space edit capability (used as fallback alongside space-scoped settings edit) - Global space edit capability (used as fallback alongside space-scoped settings edit)
- space.delete - space.delete
- Global space delete capability (used as fallback alongside space-scoped delete) - Global space delete capability (used as fallback alongside space-scoped settings.delete)
## Space-Scoped Permission Format ## Space-Scoped Permission Format
@@ -23,6 +23,7 @@ space.<space_permission_key>.<action>
- space_permission_key is derived from the space name (normalized token) - space_permission_key is derived from the space name (normalized token)
- Example: - Example:
- space.product_docs.note.create - space.product_docs.note.create
- space.product_docs.tasks.create
- space.product_docs.settings.member.manage - space.product_docs.settings.member.manage
## Space-Scoped Actions Currently Enforced ## Space-Scoped Actions Currently Enforced
@@ -30,7 +31,7 @@ space.<space_permission_key>.<action>
### Space Management ### Space Management
- settings.edit - settings.edit
- delete - settings.delete
### Member Management ### Member Management
@@ -49,6 +50,16 @@ space.<space_permission_key>.<action>
- note.edit - note.edit
- note.delete - note.delete
### Task Management
- tasks.create
- tasks.edit
- tasks.delete
### Task Status Management
- tasks.status.manage
## Wildcard Support ## Wildcard Support
Permissions support wildcard matching with \*. Permissions support wildcard matching with \*.
@@ -59,6 +70,8 @@ Examples:
- Grants all permissions for the product_docs space - Grants all permissions for the product_docs space
- space.\*.note.create - space.\*.note.create
- Grants note.create for all spaces - Grants note.create for all spaces
- space.\*.tasks.\*
- Grants all task permissions for all spaces
- `*` - `*`
- Grants all permissions globally - Grants all permissions globally

View File

@@ -1,304 +1,151 @@
# 🚀 Quick Start Guide # Quick Start
## Prerequisites This guide covers the fastest way to run Notely and the current local-development workflow.
- Docker and Docker Compose (recommended for quickest setup) ## Option 1: Docker Compose
- OR: Go 1.21+, Node.js 18+, MongoDB 7.0+
## Option 1: Docker Compose (Recommended - 1 Command) From the repository root:
```bash ```bash
# Clone/navigate to project cp .env.example .env
cd noteapp docker compose up -d --build
# Start everything
docker-compose up
# Wait for services to initialize (~30 seconds)
# Then open: http://localhost
``` ```
**Services running**: Open:
- Notely: http://localhost:8080 - App UI: `http://localhost`
- MongoDB: localhost:27017 - Backend health endpoint: `http://localhost:8080/health`
- Nginx Reverse Proxy: http://localhost:80 - MongoDB: `localhost:27017`
- Redis: `localhost:6379`
**Test user (after startup)**: Compose starts four services:
- Register a new account at http://localhost/register - `mongodb`
- Login and create a Space - `redis`
- Add Categories and Notes - `notely`
- `nginx`
## Option 2: Local Development ## Option 2: Local Development
### Backend Setup ### Prerequisites
- Go 1.25+
- Node.js 18+
- MongoDB
- Redis
If you do not already have MongoDB and Redis running locally, you can start just those services with Docker Compose:
```bash
docker compose up -d mongodb redis
```
### Backend
```bash ```bash
cd backend cd backend
# Copy environment file
cp .env.example .env cp .env.example .env
# Install dependencies
go mod download 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 go run ./cmd/server/main.go
# Logs should show: "Server starting on port 8080"
``` ```
### Frontend Setup The backend listens on `http://localhost:8080` by default.
### Frontend
```bash ```bash
cd frontend cd frontend
# Copy environment file
cp .env.example .env cp .env.example .env
# Install dependencies
npm install npm install
# Start dev server
npm run dev npm run dev
# Open: http://localhost:5173 in browser
``` ```
## 🧪 Testing The Vite dev server listens on `http://localhost:5173` and proxies `/api` to `http://localhost:8080`.
### Backend Tests ## Day-To-Day Commands
### Backend
```bash ```bash
cd backend cd backend
# Run all tests
go test ./... go test ./...
go test -v ./tests/unit/...
# Run with verbose output go test -v ./tests/integration/...
go test -v ./...
# Run specific test
go test -v -run TestRegisterUser ./tests/unit/...
# With coverage
go test -cover ./...
``` ```
### Frontend Tests ### Frontend
```bash ```bash
cd frontend cd frontend
npm run build
# Run tests npm run lint
npm run test npm run test
# Watch mode
npm run test:watch
# Coverage
npm run test:coverage
``` ```
## 📝 Key API Endpoints ## First Run Checklist
### Authentication 1. Register a user or set `DEFAULT_ADMIN_*` values in your env file before startup.
2. Sign in.
3. Create a space.
4. Create categories and notes.
5. Use the top search bar to verify note search.
```bash ## Useful Endpoints
# 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 Authentication:
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 - `POST /api/v1/auth/register`
``` - `POST /api/v1/auth/login`
- `POST /api/v1/auth/refresh`
- `GET /api/v1/auth/me`
### Create Space Spaces:
```bash - `GET /api/v1/spaces`
curl -X POST http://localhost:8080/api/v1/spaces \ - `POST /api/v1/spaces`
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ - `PUT /api/v1/spaces/{spaceId}`
-H "Content-Type: application/json" \ - `DELETE /api/v1/spaces/{spaceId}`
-d '{
"name": "My First Space",
"description": "Notes for my project",
"icon": "📚",
"is_public": false
}'
```
### Create Note Notes:
```bash - `GET /api/v1/spaces/{spaceId}/notes`
curl -X POST http://localhost:8080/api/v1/spaces/{spaceId}/notes \ - `POST /api/v1/spaces/{spaceId}/notes`
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ - `GET /api/v1/spaces/{spaceId}/notes/search?q=<query>`
-H "Content-Type: application/json" \ - `POST /api/v1/spaces/{spaceId}/notes/{noteId}/unlock`
-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 Public access:
```bash - `GET /api/v1/public/spaces`
curl "http://localhost:8080/api/v1/spaces/{spaceId}/notes/search?q=important" \ - `GET /api/v1/public/spaces/{spaceId}/notes`
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
## 🔍 Troubleshooting ## Troubleshooting
### MongoDB Connection Error ### Backend cannot connect to MongoDB
``` Check `MONGODB_URI` in your selected env file and make sure MongoDB is reachable.
Error: Failed to connect to database
Solution: ### Backend cannot connect to Redis
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 Check `REDIS_ADDR`, `REDIS_PASSWORD`, and `REDIS_DB`. For local defaults, Redis should usually be reachable at `localhost:6379`.
```bash ### The browser cannot reach the API in local dev
# Find process on port 8080
lsof -i :8080
# Kill it Check:
kill -9 <PID>
# Or use different port - backend is running on port `8080`
PORT=8081 go run ./cmd/server/main.go - frontend and API are reachable through the same host/origin
``` - Vite proxy settings in `frontend/vite.config.js`
### CORS Errors ### OAuth callback redirects to the wrong URL
Make sure frontend and backend URLs match in: Check `FRONTEND_URL` in your selected env file.
- Frontend: `VITE_API_BASE_URL` in `.env` ### Permission-denied behavior is unclear
- Backend: `CORS_ALLOWED_ORIGINS` in `.env`
### MongoDB Auth Failed Read `PERMISSIONS.md` and then inspect the relevant backend service in `backend/internal/application/services/`.
If using Docker Compose: ## Related Docs
- Username: `admin` - `README.md`
- Password: `password` - `ENV_SETUP.md`
- Connection string includes `?authSource=admin` - `PERMISSIONS.md`
## 📚 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! 🎉**

543
README.md
View File

@@ -1,306 +1,174 @@
# Notely - Secure Multi-Space Note-Taking Application # Notely
A production-ready, secure multi-tenant note-taking platform built with Go, Vue 3, and MongoDB. Notely is a multi-space note application built with Go, Vue 3, MongoDB, and Redis.
## 🚀 Quick Start The repository contains a Go backend, a Vue frontend, Docker Compose assets for local deployment, and Kubernetes manifests for cluster deployment. In containerized environments, the frontend is built into the backend image and served by the Go server. Docker Compose also places Nginx in front of the app for HTTP and HTTPS entry points.
### Prerequisites ## What Is In This Repo
- Docker & Docker Compose - Backend API in `backend/`
- Go 1.21+ (for local development) - Frontend SPA in `frontend/`
- Node.js 18+ (for frontend development) - Docker and Nginx assets in `devops/docker/`
- MongoDB 7.0+ (for local development) - Kubernetes manifests in `devops/kubernetes/`
- Root documentation in `README.md`, `QUICKSTART.md`, `ENV_SETUP.md`, and `PERMISSIONS.md`
### Development with Docker Compose ## Core Features
- Email/password authentication
- Session cookies backed by Redis, with bearer-token fallback for API clients
- Admin bootstrap from environment variables
- Permission-based authorization with wildcard support
- Spaces, categories, and notes
- Full-text note search
- Public spaces and public notes
- Password-protected notes
- OAuth/OIDC provider support
- Feature flags for registration, provider login, public sharing, and file explorer support
- Optional S3-compatible file explorer when enabled through feature flags
## Architecture Overview
### Backend
- Language: Go
- Module: `gitea.hostxtra.co.uk/mrhid6/notely/backend`
- Entry point: `backend/cmd/server/main.go`
- Architecture style: domain/application/infrastructure/interfaces split
- Storage: MongoDB
- Session store: Redis
### Frontend
- Framework: Vue 3
- Router: Vue Router
- State: Pinia
- Build tool: Vite
### Container Layout
- `devops/docker/Dockerfile` builds the frontend and backend into a single app image
- `docker-compose.yml` starts:
- `mongodb`
- `redis`
- `notely` (combined app image)
- `nginx`
## Documentation Map
- `README.md`: project overview and current architecture
- `QUICKSTART.md`: fast setup and day-to-day development commands
- `ENV_SETUP.md`: environment-variable reference and configuration layout
- `PERMISSIONS.md`: enforced permission model and naming
## Getting Started
### Docker Compose
1. Copy the root environment file:
```bash ```bash
# Start all services cp .env.example .env
docker-compose up
# Backend: http://localhost:8080
# Frontend: http://localhost:5173
# MongoDB: localhost:27017
# Nginx: http://localhost:80
``` ```
### Local Development Setup 2. Start the stack:
#### Backend ```bash
docker compose up -d --build
```
3. Open the app:
- UI through Nginx: `http://localhost`
- Backend health check: `http://localhost:8080/health`
- MongoDB: `localhost:27017`
- Redis: `localhost:6379`
### Local Development
Prerequisites:
- Go 1.25+
- Node.js 18+
- MongoDB
- Redis
Backend:
```bash ```bash
cd backend cd backend
cp .env.example .env
# Install dependencies
go mod download 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 go run ./cmd/server/main.go
``` ```
#### Frontend Frontend:
```bash ```bash
cd frontend cd frontend
cp .env.example .env
# Install dependencies
npm install npm install
# Start development server
npm run dev npm run dev
``` ```
## 📚 Architecture Local frontend development runs at `http://localhost:5173` and proxies `/api` requests to `http://localhost:8080`.
### Backend (GoClean Architecture) ## API Surface
``` The router in `backend/cmd/server/main.go` currently exposes these endpoint groups.
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) ### Public Endpoints
``` - `GET /health`
frontend/ - `POST /api/v1/auth/register`
├── src/ - `POST /api/v1/auth/login`
│ ├── components/ # Reusable Vue components - `POST /api/v1/auth/refresh`
│ ├── pages/ # Page components - `POST /api/v1/auth/logout`
│ ├── stores/ # Pinia state management - `GET /api/v1/auth/providers`
│ ├── services/ # API client - `GET /api/v1/auth/providers/{providerId}/start`
│ ├── router/ # Vue Router config - `GET /api/v1/auth/providers/{providerId}/callback`
│ ├── assets/ # Styles and assets - `GET /api/v1/settings/feature-flags`
│ └── main.js # Entry point - `GET /api/v1/public/spaces`
├── index.html - `GET /api/v1/public/spaces/{spaceId}`
└── vite.config.js - `GET /api/v1/public/spaces/{spaceId}/notes`
``` - `GET /api/v1/public/spaces/{spaceId}/notes/{noteId}`
- `POST /api/v1/public/spaces/{spaceId}/notes/{noteId}/unlock`
## 🔐 Security Features ### Authenticated User Endpoints
### Authentication - `GET /api/v1/auth/me`
- Space CRUD under `/api/v1/spaces`
- Space member management under `/api/v1/spaces/{spaceId}/members`
- Note CRUD, search, and unlock under `/api/v1/spaces/{spaceId}/notes`
- Category CRUD and move under `/api/v1/spaces/{spaceId}/categories`
- File explorer operations under `/api/v1/spaces/{spaceId}/files`
- **Argon2id password hashing** - Industry-standard PBKDF2 ### Admin Endpoints
- **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 Admin routes live under `/api/v1/admin` and cover:
- **Role-based access control (RBAC)** per space: - users
- Owner: Full control - groups
- Editor: Edit notes and categories - spaces
- Viewer: Read-only access - feature flags
- **Space-level data isolation** - all queries include space_id - auth providers
- **IDOR prevention** - middleware enforces ownership verification
### Data Security ## Permissions
- **Encryption at rest** for sensitive fields (OAuth secrets) Notely uses permission-based authorization, not fixed owner/editor/viewer roles.
- **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 - Global permissions include `space.create`, `space.edit`, and `space.delete`
- Space-scoped permissions follow `space.<space_key>.<action>`
- Example: `space.product_docs.note.create`
- Example: `space.product_docs.settings.delete`
- Space deletion requires either:
- global `space.delete`, or
- space-scoped `space.<space_key>.settings.delete`
- **Rate limiting** - IP-based and user-based See `PERMISSIONS.md` for the current enforced permission set.
- **Security headers** - HSTS, X-Frame-Options, X-Content-Type-Options
- **CORS properly configured** - whitelist origin domains
- **Input validation** on all endpoints
## 📦 API Endpoints ## Testing And Quality Checks
### Authentication Backend:
```
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 ```bash
cd backend cd backend
@@ -309,118 +177,73 @@ go test -v ./tests/unit/...
go test -v ./tests/integration/... go test -v ./tests/integration/...
``` ```
### Frontend Tests Frontend:
```bash ```bash
cd frontend cd frontend
npm run build
npm run lint
npm run test npm run test
npm run test:watch
``` ```
## 🔧 Configuration ## Deployment Notes
### Environment Variables ### Docker Compose
#### Backend (.env) Docker Compose uses the combined application image plus Nginx, MongoDB, and Redis. Configuration is driven by the root `.env` file.
``` ### Kubernetes
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) The manifest at `devops/kubernetes/deployment.yaml` currently provisions:
``` - `noteapp` namespace
VITE_API_BASE_URL=http://localhost:8080 - MongoDB StatefulSet and PVC
``` - single `noteapp` Deployment for the combined app image
- ClusterIP services
- Ingress
- HorizontalPodAutoscaler
## 📝 Development Guidelines Apply it with:
### 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 ```bash
# Example: Create Note kubectl apply -f devops/kubernetes/deployment.yaml
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 ## Current Repo Layout
All errors return appropriate HTTP status codes: ```text
noteapp/
├── backend/
│ ├── cmd/server/
│ ├── internal/
│ ├── pkg/
│ ├── tests/
│ └── .env.example
├── frontend/
│ ├── src/
│ ├── tests/
│ ├── package.json
│ ├── vite.config.js
│ ├── vitest.config.js
│ └── .env.example
├── devops/
│ ├── docker/
│ │ ├── Dockerfile
│ │ ├── nginx.conf
│ │ └── ssl/
│ └── kubernetes/
│ └── deployment.yaml
├── docker-compose.yml
├── .env.example
├── ENV_SETUP.md
├── PERMISSIONS.md
├── QUICKSTART.md
└── README.md
```
- `400` - Bad Request ## Notes For Contributors
- `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 - Check `PERMISSIONS.md` when changing authorization behavior
- Check `ENV_SETUP.md` when adding or changing configuration
- [ ] OAuth2/OIDC integration - Check `backend/cmd/server/main.go` before documenting routes
- [ ] Email notifications - Keep docs aligned with actual package scripts and checked-in files
- [ ] 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**

View File

@@ -1,284 +0,0 @@
# 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

View File

@@ -26,3 +26,9 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
# Rate Limiting # Rate Limiting
RATE_LIMIT_REQUESTS=50 RATE_LIMIT_REQUESTS=50
RATE_LIMIT_WINDOW=1s RATE_LIMIT_WINDOW=1s
# Redis Sessions
REDIS_ADDR=localhost:6379
REDIS_PASSWORD=
REDIS_DB=0
SESSION_TTL_HOURS=168

View File

@@ -6,19 +6,21 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/database"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/handlers"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/noteapp/backend/internal/application/services" "github.com/redis/go-redis/v9"
"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" "go.mongodb.org/mongo-driver/v2/bson"
) )
@@ -47,6 +49,31 @@ func main() {
port = "8080" port = "8080"
} }
redisAddr := os.Getenv("REDIS_ADDR")
if redisAddr == "" {
redisAddr = "localhost:6379"
}
redisUser := os.Getenv("REDIS_USER")
redisPassword := os.Getenv("REDIS_PASSWORD")
redisDB := 0
if redisDBText := os.Getenv("REDIS_DB"); redisDBText != "" {
parsedDB, err := strconv.Atoi(redisDBText)
if err != nil {
log.Fatalf("invalid REDIS_DB value: %v", err)
}
redisDB = parsedDB
}
sessionTTL := 7 * 24 * time.Hour
if sessionTTLText := os.Getenv("SESSION_TTL_HOURS"); sessionTTLText != "" {
hours, err := strconv.Atoi(sessionTTLText)
if err != nil || hours <= 0 {
log.Fatalf("invalid SESSION_TTL_HOURS value: %q", sessionTTLText)
}
sessionTTL = time.Duration(hours) * time.Hour
}
// Connect to database // Connect to database
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@@ -57,6 +84,20 @@ func main() {
} }
defer db.Close(context.Background()) defer db.Close(context.Background())
redisClient := redis.NewClient(&redis.Options{
Addr: redisAddr,
Username: redisUser,
Password: redisPassword,
DB: redisDB,
})
if err := redisClient.Ping(context.Background()).Err(); err != nil {
log.Fatalf("failed to connect to redis: %v", err)
}
defer func() {
_ = redisClient.Close()
}()
// Initialize security components // Initialize security components
passwordHasher := security.NewPasswordHasher() passwordHasher := security.NewPasswordHasher()
encryptor, err := security.NewEncryptor(encryptionKey) encryptor, err := security.NewEncryptor(encryptionKey)
@@ -66,6 +107,7 @@ func main() {
// Initialize JWT manager // Initialize JWT manager
jwtManager := auth.NewJWTManager(jwtSecret, "noteapp", 1*time.Hour) jwtManager := auth.NewJWTManager(jwtSecret, "noteapp", 1*time.Hour)
sessionManager := auth.NewSessionManager(redisClient, sessionTTL)
// Initialize services // Initialize services
permissionService := services.NewPermissionService( permissionService := services.NewPermissionService(
@@ -93,6 +135,7 @@ func main() {
db.MembershipRepo, db.MembershipRepo,
db.NoteRepo, db.NoteRepo,
db.CategoryRepo, db.CategoryRepo,
db.TaskListRepo,
db.UserRepo, db.UserRepo,
permissionService, permissionService,
) )
@@ -109,20 +152,34 @@ func main() {
categoryService := services.NewCategoryService( categoryService := services.NewCategoryService(
db.CategoryRepo, db.CategoryRepo,
db.TaskListRepo,
db.MembershipRepo, db.MembershipRepo,
db.NoteRepo, db.NoteRepo,
permissionService, permissionService,
) )
taskService := services.NewTaskService(
db.TaskRepo,
db.TaskListRepo,
db.TaskStatusRepo,
db.NoteRepo,
db.CategoryRepo,
db.MembershipRepo,
permissionService,
)
adminService := services.NewAdminService( adminService := services.NewAdminService(
db.UserRepo, db.UserRepo,
db.GroupRepo, db.GroupRepo,
db.ProviderRepo,
db.LinkRepo,
db.SpaceRepo, db.SpaceRepo,
db.MembershipRepo, db.MembershipRepo,
db.NoteRepo, db.NoteRepo,
db.CategoryRepo, db.CategoryRepo,
db.FeatureFlagRepo, db.FeatureFlagRepo,
permissionService, permissionService,
encryptor,
) )
if err := permissionService.EnsureAdminGroup(context.Background()); err != nil { if err := permissionService.EnsureAdminGroup(context.Background()); err != nil {
@@ -140,13 +197,16 @@ func main() {
} }
// Initialize handlers // Initialize handlers
authHandler := handlers.NewAuthHandler(authService) authHandler := handlers.NewAuthHandler(authService, sessionManager)
spaceHandler := handlers.NewSpaceHandler(spaceService) spaceHandler := handlers.NewSpaceHandler(spaceService)
noteHandler := handlers.NewNoteHandler(noteService) noteHandler := handlers.NewNoteHandler(noteService)
categoryHandler := handlers.NewCategoryHandler(categoryService) categoryHandler := handlers.NewCategoryHandler(categoryService)
taskHandler := handlers.NewTaskHandler(taskService)
adminHandler := handlers.NewAdminHandler(adminService) adminHandler := handlers.NewAdminHandler(adminService)
publicHandler := handlers.NewPublicHandler(spaceService, noteService) publicHandler := handlers.NewPublicHandler(spaceService, noteService)
settingsHandler := handlers.NewSettingsHandler(authService) settingsHandler := handlers.NewSettingsHandler(authService)
fileService := services.NewFileService(db.FeatureFlagRepo, db.MembershipRepo, encryptor)
fileHandler := handlers.NewFileHandler(fileService)
// Create router // Create router
router := mux.NewRouter() router := mux.NewRouter()
@@ -155,7 +215,7 @@ func main() {
}) })
// Middleware // Middleware
authMiddleware := middleware.NewAuthMiddleware(jwtManager) authMiddleware := middleware.NewAuthMiddleware(jwtManager, sessionManager)
router.Use(middleware.LoggingMiddleware) router.Use(middleware.LoggingMiddleware)
router.Use(middleware.CORSMiddleware) router.Use(middleware.CORSMiddleware)
router.Use(middleware.SecurityHeaders) router.Use(middleware.SecurityHeaders)
@@ -182,6 +242,7 @@ func main() {
// Protected endpoints // Protected endpoints
api := router.PathPrefix("/api/v1").Subrouter() api := router.PathPrefix("/api/v1").Subrouter()
api.Use(authMiddleware.Middleware) api.Use(authMiddleware.Middleware)
api.HandleFunc("/auth/me", authHandler.Me).Methods("GET")
// Space endpoints // Space endpoints
api.HandleFunc("/spaces", spaceHandler.GetUserSpaces).Methods("GET") api.HandleFunc("/spaces", spaceHandler.GetUserSpaces).Methods("GET")
@@ -210,6 +271,38 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE") api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH") api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
// Task endpoints
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.ListTaskLists).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.CreateTaskList).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.UpdateTaskList).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.DeleteTaskList).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.GetTask).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.UpdateTask).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.DeleteTask).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/transition", taskHandler.TransitionTaskStatus).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes", taskHandler.LinkTaskNote).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
// Task status endpoints (scoped to task list)
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
// File explorer endpoints (space-scoped)
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.GetFile).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/files/upload", fileHandler.UploadFile).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/files/folder", fileHandler.CreateFolder).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.DeleteFile).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/files/folder", fileHandler.DeleteFolder).Methods("DELETE")
// Admin endpoints // Admin endpoints
admin := router.PathPrefix("/api/v1/admin").Subrouter() admin := router.PathPrefix("/api/v1/admin").Subrouter()
admin.Use(authMiddleware.Middleware) admin.Use(authMiddleware.Middleware)
@@ -244,10 +337,12 @@ func main() {
}) })
}) })
admin.HandleFunc("/users", adminHandler.ListUsers).Methods("GET") admin.HandleFunc("/users", adminHandler.ListUsers).Methods("GET")
admin.HandleFunc("/users/{userId}", adminHandler.DeleteUser).Methods("DELETE")
admin.HandleFunc("/users/{userId}/groups", adminHandler.UpdateUserGroups).Methods("PUT") admin.HandleFunc("/users/{userId}/groups", adminHandler.UpdateUserGroups).Methods("PUT")
admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET") admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET")
admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST") admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST")
admin.HandleFunc("/groups/{groupId}", adminHandler.UpdateGroup).Methods("PUT") admin.HandleFunc("/groups/{groupId}", adminHandler.UpdateGroup).Methods("PUT")
admin.HandleFunc("/groups/{groupId}", adminHandler.DeleteGroup).Methods("DELETE")
admin.HandleFunc("/spaces", adminHandler.ListAllSpaces).Methods("GET") admin.HandleFunc("/spaces", adminHandler.ListAllSpaces).Methods("GET")
admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT") admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT")
admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE") admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE")
@@ -258,7 +353,10 @@ func main() {
admin.HandleFunc("/feature-flags", adminHandler.GetFeatureFlags).Methods("GET") admin.HandleFunc("/feature-flags", adminHandler.GetFeatureFlags).Methods("GET")
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT") admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
// manage identity providers — admin-only // manage identity providers — admin-only
admin.HandleFunc("/auth/providers", authHandler.ListProvidersForAdmin).Methods("GET")
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST") admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST")
admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT")
admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE")
// Serve static files (frontend) for all other routes // Serve static files (frontend) for all other routes
// This must be after all API route handlers to allow API routes to take precedence // This must be after all API route handlers to allow API routes to take precedence

View File

@@ -1,22 +1,38 @@
module github.com/noteapp/backend module gitea.hostxtra.co.uk/mrhid6/notely/backend
go 1.25.0 go 1.25.0
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.4
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2
github.com/golang-jwt/jwt/v5 v5.2.0 github.com/golang-jwt/jwt/v5 v5.2.0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.18.0
go.mongodb.org/mongo-driver/v2 v2.5.0 go.mongodb.org/mongo-driver/v2 v2.5.0
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
golang.org/x/oauth2 v0.30.0 golang.org/x/oauth2 v0.30.0
) )
require ( require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/compress v1.17.6 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect

View File

@@ -1,5 +1,37 @@
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -10,6 +42,14 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/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 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 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 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
@@ -19,8 +59,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-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.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 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=

View File

@@ -1,7 +1,7 @@
package dto package dto
import ( import (
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
) )
// ========== AUTH DTOs ========== // ========== AUTH DTOs ==========
@@ -57,18 +57,45 @@ type CreateAuthProviderRequest struct {
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
} }
// UpdateAuthProviderRequest represents an OAuth/OIDC provider update request.
// ClientSecret may be empty to keep the existing secret.
type UpdateAuthProviderRequest struct {
Name string `json:"name"`
Type string `json:"type"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
AuthorizationURL string `json:"authorization_url"`
TokenURL string `json:"token_url"`
UserInfoURL string `json:"userinfo_url"`
Scopes []string `json:"scopes"`
IDTokenClaim string `json:"id_token_claim,omitempty"`
IsActive bool `json:"is_active"`
}
// FeatureFlagsDTO represents app-wide feature flags in API responses. // FeatureFlagsDTO represents app-wide feature flags in API responses.
type FeatureFlagsDTO struct { type FeatureFlagsDTO struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
ProviderLoginEnabled bool `json:"provider_login_enabled"` ProviderLoginEnabled bool `json:"provider_login_enabled"`
PublicSharingEnabled bool `json:"public_sharing_enabled"` PublicSharingEnabled bool `json:"public_sharing_enabled"`
FileExplorerEnabled bool `json:"file_explorer_enabled"`
S3Endpoint string `json:"s3_endpoint,omitempty"`
S3Bucket string `json:"s3_bucket,omitempty"`
S3Region string `json:"s3_region,omitempty"`
S3AccessKey string `json:"s3_access_key,omitempty"`
S3SecretKeySet bool `json:"s3_secret_key_set"`
} }
// UpdateFeatureFlagsRequest represents admin payload for feature flag updates. // UpdateFeatureFlagsRequest represents admin payload for feature flag updates.
type UpdateFeatureFlagsRequest struct { type UpdateFeatureFlagsRequest struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
ProviderLoginEnabled bool `json:"provider_login_enabled"` ProviderLoginEnabled bool `json:"provider_login_enabled"`
PublicSharingEnabled bool `json:"public_sharing_enabled"` PublicSharingEnabled bool `json:"public_sharing_enabled"`
FileExplorerEnabled bool `json:"file_explorer_enabled"`
S3Endpoint string `json:"s3_endpoint"`
S3Bucket string `json:"s3_bucket"`
S3Region string `json:"s3_region"`
S3AccessKey string `json:"s3_access_key"`
S3SecretKey string `json:"s3_secret_key"` // empty = keep existing encrypted value
} }
// UserDTO represents a user in API responses // UserDTO represents a user in API responses
@@ -206,6 +233,12 @@ func NewFeatureFlagsDTO(flags *entities.FeatureFlags) *FeatureFlagsDTO {
RegistrationEnabled: flags.RegistrationEnabled, RegistrationEnabled: flags.RegistrationEnabled,
ProviderLoginEnabled: flags.ProviderLoginEnabled, ProviderLoginEnabled: flags.ProviderLoginEnabled,
PublicSharingEnabled: flags.PublicSharingEnabled, PublicSharingEnabled: flags.PublicSharingEnabled,
FileExplorerEnabled: flags.FileExplorerEnabled,
S3Endpoint: flags.S3Endpoint,
S3Bucket: flags.S3Bucket,
S3Region: flags.S3Region,
S3AccessKey: flags.S3AccessKey,
S3SecretKeySet: flags.S3SecretKey != "",
} }
} }
@@ -397,6 +430,7 @@ type CategoryTreeDTO struct {
*CategoryDTO *CategoryDTO
Subcategories []*CategoryTreeDTO `json:"subcategories"` Subcategories []*CategoryTreeDTO `json:"subcategories"`
Notes []*NoteListItemDTO `json:"notes"` Notes []*NoteListItemDTO `json:"notes"`
TaskLists []*TaskListDTO `json:"task_lists"`
} }
// NewCategoryDTO creates a DTO from a category entity // NewCategoryDTO creates a DTO from a category entity
@@ -419,6 +453,183 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO {
} }
} }
// ========== TASK DTOs ==========
// CreateTaskRequest represents task creation input.
type CreateTaskRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Description string `json:"description" validate:"max=2000"`
TaskListID string `json:"task_list_id" validate:"required"`
StatusID string `json:"status_id" validate:"required"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links"`
}
// UpdateTaskRequest represents task update input.
type UpdateTaskRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
TaskListID *string `json:"task_list_id,omitempty"`
StatusID *string `json:"status_id,omitempty"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links,omitempty"`
}
// TaskTransitionRequest allows moving task status by one step.
type TaskTransitionRequest struct {
Direction string `json:"direction" validate:"required,oneof=forward backward"`
}
// LinkTaskNoteRequest links/unlinks a note from a task.
type LinkTaskNoteRequest struct {
NoteID string `json:"note_id" validate:"required"`
}
// TaskDTO represents a task in API responses.
type TaskDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
Title string `json:"title"`
Description string `json:"description"`
TaskListID string `json:"task_list_id"`
StatusID string `json:"status_id"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
Depth int `json:"depth"`
NoteLinks []string `json:"note_links"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// TaskWithStatusDTO includes status details and child tasks for detail views.
type TaskWithStatusDTO struct {
*TaskDTO
StatusName string `json:"status_name"`
StatusColor string `json:"status_color,omitempty"`
StatusOrder int `json:"status_order"`
Subtasks []*TaskDTO `json:"subtasks"`
}
// CreateTaskStatusRequest represents task status creation input.
type CreateTaskStatusRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Color string `json:"color,omitempty" validate:"max=20"`
}
// UpdateTaskStatusRequest represents task status updates.
type UpdateTaskStatusRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Color string `json:"color,omitempty" validate:"max=20"`
}
// ReorderTaskStatusesRequest represents a full ordered status ID list.
type ReorderTaskStatusesRequest struct {
OrderedStatusIDs []string `json:"ordered_status_ids"`
}
// TaskStatusDTO represents a task status in API responses.
type TaskStatusDTO struct {
ID string `json:"id"`
TaskListID string `json:"task_list_id"`
Name string `json:"name"`
Color string `json:"color,omitempty"`
Order int `json:"order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CreateTaskListRequest represents task list creation input.
type CreateTaskListRequest struct {
Name string `json:"name" validate:"required,min=1,max=120"`
Description string `json:"description" validate:"max=500"`
CategoryID *string `json:"category_id,omitempty"`
}
// UpdateTaskListRequest represents task list update input.
type UpdateTaskListRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
}
// TaskListDTO represents a task list in API responses.
type TaskListDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
CategoryID *string `json:"category_id,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NewTaskDTO creates a DTO from a task entity.
func NewTaskDTO(task *entities.Task) *TaskDTO {
var parentTaskID *string
if task.ParentTaskID != nil {
id := task.ParentTaskID.Hex()
parentTaskID = &id
}
noteLinks := make([]string, 0, len(task.NoteLinks))
for _, noteID := range task.NoteLinks {
noteLinks = append(noteLinks, noteID.Hex())
}
return &TaskDTO{
ID: task.ID.Hex(),
SpaceID: task.SpaceID.Hex(),
Title: task.Title,
Description: task.Description,
TaskListID: task.TaskListID.Hex(),
StatusID: task.StatusID.Hex(),
ParentTaskID: parentTaskID,
Depth: task.Depth,
NoteLinks: noteLinks,
CreatedBy: task.CreatedBy.Hex(),
UpdatedBy: task.UpdatedBy.Hex(),
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: task.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// NewTaskListDTO creates a DTO from a task list entity.
func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
var categoryID *string
if taskList.CategoryID != nil {
id := taskList.CategoryID.Hex()
categoryID = &id
}
return &TaskListDTO{
ID: taskList.ID.Hex(),
SpaceID: taskList.SpaceID.Hex(),
CategoryID: categoryID,
Name: taskList.Name,
Description: taskList.Description,
CreatedBy: taskList.CreatedBy.Hex(),
UpdatedBy: taskList.UpdatedBy.Hex(),
CreatedAt: taskList.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: taskList.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// NewTaskStatusDTO creates a DTO from a task status entity.
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
return &TaskStatusDTO{
ID: status.ID.Hex(),
TaskListID: status.TaskListID.Hex(),
Name: status.Name,
Color: status.Color,
Order: status.Order,
CreatedAt: status.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: status.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ========== ERROR DTOs ========== // ========== ERROR DTOs ==========
// ErrorResponse represents an error response // ErrorResponse represents an error response

View File

@@ -7,46 +7,164 @@ import (
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
) )
// AdminService handles admin-level operations // AdminService handles admin-level operations
type AdminService struct { type AdminService struct {
userRepo repositories.UserRepository userRepo repositories.UserRepository
groupRepo repositories.GroupRepository groupRepo repositories.GroupRepository
providerRepo repositories.AuthProviderRepository
linkRepo repositories.UserProviderLinkRepository
spaceRepo repositories.SpaceRepository spaceRepo repositories.SpaceRepository
membershipRepo repositories.MembershipRepository membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
featureFlagRepo repositories.FeatureFlagRepository featureFlagRepo repositories.FeatureFlagRepository
permissionService *PermissionService permissionService *PermissionService
encryptor *security.Encryptor
} }
// NewAdminService creates a new AdminService // NewAdminService creates a new AdminService
func NewAdminService( func NewAdminService(
userRepo repositories.UserRepository, userRepo repositories.UserRepository,
groupRepo repositories.GroupRepository, groupRepo repositories.GroupRepository,
providerRepo repositories.AuthProviderRepository,
linkRepo repositories.UserProviderLinkRepository,
spaceRepo repositories.SpaceRepository, spaceRepo repositories.SpaceRepository,
membershipRepo repositories.MembershipRepository, membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
featureFlagRepo repositories.FeatureFlagRepository, featureFlagRepo repositories.FeatureFlagRepository,
permissionService *PermissionService, permissionService *PermissionService,
encryptor *security.Encryptor,
) *AdminService { ) *AdminService {
return &AdminService{ return &AdminService{
userRepo: userRepo, userRepo: userRepo,
groupRepo: groupRepo, groupRepo: groupRepo,
providerRepo: providerRepo,
linkRepo: linkRepo,
spaceRepo: spaceRepo, spaceRepo: spaceRepo,
membershipRepo: membershipRepo, membershipRepo: membershipRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
featureFlagRepo: featureFlagRepo, featureFlagRepo: featureFlagRepo,
permissionService: permissionService, permissionService: permissionService,
encryptor: encryptor,
} }
} }
// DeleteUser deletes a user and related memberships/provider links.
func (s *AdminService) DeleteUser(ctx context.Context, currentUserID, targetUserID bson.ObjectID) error {
if currentUserID == targetUserID {
return errors.New("you cannot delete your own account")
}
spaces, err := s.spaceRepo.GetAllSpaces(ctx)
if err != nil {
return err
}
for _, space := range spaces {
if space.OwnerID == targetUserID {
return errors.New("cannot delete user that owns spaces; transfer or delete spaces first")
}
}
memberships, err := s.membershipRepo.GetUserMemberships(ctx, targetUserID)
if err == nil {
for _, membership := range memberships {
if err := s.membershipRepo.DeleteMembership(ctx, membership.ID); err != nil {
return err
}
}
}
if s.linkRepo != nil {
links, err := s.linkRepo.GetUserLinks(ctx, targetUserID)
if err == nil {
for _, link := range links {
if err := s.linkRepo.DeleteLink(ctx, link.ID); err != nil {
return err
}
}
}
}
return s.userRepo.DeleteUser(ctx, targetUserID)
}
// DeleteGroup deletes a non-system group and removes it from users.
func (s *AdminService) DeleteGroup(ctx context.Context, groupID bson.ObjectID) error {
group, err := s.groupRepo.GetGroupByID(ctx, groupID)
if err != nil {
return err
}
if group.IsSystem {
return errors.New("system groups cannot be deleted")
}
users, err := s.userRepo.ListAllUsers(ctx)
if err != nil {
return err
}
for _, user := range users {
filtered := make([]bson.ObjectID, 0, len(user.GroupIDs))
changed := false
for _, assignedGroupID := range user.GroupIDs {
if assignedGroupID == groupID {
changed = true
continue
}
filtered = append(filtered, assignedGroupID)
}
if !changed {
continue
}
user.GroupIDs = filtered
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
return err
}
}
if err := s.groupRepo.DeleteGroup(ctx, groupID); err != nil {
return err
}
return s.refreshAllUserPermissions(ctx)
}
// DeleteProvider deletes an auth provider and all user-provider links connected to it.
func (s *AdminService) DeleteProvider(ctx context.Context, providerID bson.ObjectID) error {
if s.providerRepo == nil {
return errors.New("provider repository unavailable")
}
if s.linkRepo != nil {
users, err := s.userRepo.ListAllUsers(ctx)
if err != nil {
return err
}
for _, user := range users {
links, err := s.linkRepo.GetUserLinks(ctx, user.ID)
if err != nil {
continue
}
for _, link := range links {
if link.ProviderID == providerID {
if err := s.linkRepo.DeleteLink(ctx, link.ID); err != nil {
return err
}
}
}
}
}
return s.providerRepo.DeleteProvider(ctx, providerID)
}
// ListUsers returns all users as admin DTOs // ListUsers returns all users as admin DTOs
func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) { func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) {
users, err := s.userRepo.ListAllUsers(ctx) users, err := s.userRepo.ListAllUsers(ctx)
@@ -299,10 +417,31 @@ func (s *AdminService) UpdateFeatureFlags(ctx context.Context, req *dto.UpdateFe
return nil, errors.New("feature flags are unavailable") return nil, errors.New("feature flags are unavailable")
} }
// Load existing flags so we can preserve the encrypted S3 secret when not updated
existing, err := s.featureFlagRepo.GetFeatureFlags(ctx)
if err != nil {
existing = entities.NewDefaultFeatureFlags()
}
flags := &entities.FeatureFlags{ flags := &entities.FeatureFlags{
RegistrationEnabled: req.RegistrationEnabled, RegistrationEnabled: req.RegistrationEnabled,
ProviderLoginEnabled: req.ProviderLoginEnabled, ProviderLoginEnabled: req.ProviderLoginEnabled,
PublicSharingEnabled: req.PublicSharingEnabled, PublicSharingEnabled: req.PublicSharingEnabled,
FileExplorerEnabled: req.FileExplorerEnabled,
S3Endpoint: strings.TrimSpace(req.S3Endpoint),
S3Bucket: strings.TrimSpace(req.S3Bucket),
S3Region: strings.TrimSpace(req.S3Region),
S3AccessKey: strings.TrimSpace(req.S3AccessKey),
S3SecretKey: existing.S3SecretKey, // keep encrypted secret by default
}
// Only re-encrypt if a new secret was supplied
if s.encryptor != nil && strings.TrimSpace(req.S3SecretKey) != "" {
encrypted, err := s.encryptor.Encrypt(strings.TrimSpace(req.S3SecretKey))
if err != nil {
return nil, err
}
flags.S3SecretKey = encrypted
} }
if err := s.featureFlagRepo.UpdateFeatureFlags(ctx, flags); err != nil { if err := s.featureFlagRepo.UpdateFeatureFlags(ctx, flags); err != nil {

View File

@@ -12,11 +12,11 @@ import (
"strings" "strings"
"time" "time"
"github.com/noteapp/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
"github.com/noteapp/backend/internal/infrastructure/auth" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
"github.com/noteapp/backend/internal/infrastructure/security" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@@ -114,22 +114,9 @@ func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*
} }
} }
// Generate tokens
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
if err != nil {
return nil, err
}
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
if err != nil {
return nil, err
}
return &dto.LoginResponse{ return &dto.LoginResponse{
AccessToken: accessToken, User: dto.NewUserDTO(user),
RefreshToken: refreshToken, ExpiresIn: 3600, // 1 hour
User: dto.NewUserDTO(user),
ExpiresIn: 3600, // 1 hour
}, nil }, nil
} }
@@ -165,27 +152,18 @@ func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest) (*dto.Lo
// Log error but don't fail the login // 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{ return &dto.LoginResponse{
AccessToken: accessToken, User: dto.NewUserDTO(user),
RefreshToken: refreshToken, ExpiresIn: 3600,
User: dto.NewUserDTO(user),
ExpiresIn: 3600,
}, nil }, nil
} }
// RefreshAccessToken refreshes an access token // RefreshAccessToken refreshes an access token
func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) { func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) {
if s.jwtManager == nil {
return "", errors.New("jwt refresh is unavailable")
}
claims, err := s.jwtManager.VerifyRefreshToken(refreshToken) claims, err := s.jwtManager.VerifyRefreshToken(refreshToken)
if err != nil { if err != nil {
return "", err return "", err
@@ -199,6 +177,27 @@ func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken strin
return s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username) return s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
} }
// GetUserProfile returns profile DTO for the provided user ID.
func (s *AuthService) GetUserProfile(ctx context.Context, userID string) (*dto.UserDTO, error) {
objID, err := bson.ObjectIDFromHex(strings.TrimSpace(userID))
if err != nil {
return nil, errors.New("invalid user id")
}
user, err := s.userRepo.GetUserByID(ctx, objID)
if err != nil {
return nil, err
}
if s.permissionService != nil {
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
return nil, err
}
}
return dto.NewUserDTO(user), nil
}
// RequestPasswordReset initiates password reset flow // RequestPasswordReset initiates password reset flow
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error { func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
user, err := s.userRepo.GetUserByEmail(ctx, email) user, err := s.userRepo.GetUserByEmail(ctx, email)
@@ -260,6 +259,25 @@ func (s *AuthService) ListProviders(ctx context.Context) ([]*dto.AuthProviderDTO
return result, nil return result, nil
} }
// ListProvidersForAdmin returns all OAuth/OIDC providers, including inactive ones.
func (s *AuthService) ListProvidersForAdmin(ctx context.Context) ([]*dto.AuthProviderDTO, error) {
if s.providerRepo == nil {
return []*dto.AuthProviderDTO{}, nil
}
providers, err := s.providerRepo.GetAllProvidersForAdmin(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. // GetFeatureFlags returns current app-wide feature flags.
func (s *AuthService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) { func (s *AuthService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
if s.featureFlagRepo == nil { if s.featureFlagRepo == nil {
@@ -319,6 +337,57 @@ func (s *AuthService) CreateProvider(ctx context.Context, req *dto.CreateAuthPro
return dto.NewAuthProviderDTO(provider), nil return dto.NewAuthProviderDTO(provider), nil
} }
// UpdateProvider updates an existing OAuth/OIDC provider.
// If ClientSecret is empty, the existing encrypted secret is preserved.
func (s *AuthService) UpdateProvider(ctx context.Context, providerID bson.ObjectID, req *dto.UpdateAuthProviderRequest) (*dto.AuthProviderDTO, error) {
if s.providerRepo == nil || s.encryptor == nil {
return nil, errors.New("provider configuration unavailable")
}
existing, err := s.providerRepo.GetProviderByID(ctx, providerID)
if err != nil {
return nil, err
}
providerType := strings.ToLower(strings.TrimSpace(req.Type))
if providerType != "oidc" && providerType != "oauth2" {
return nil, errors.New("provider type must be oidc or oauth2")
}
name := strings.TrimSpace(req.Name)
clientID := strings.TrimSpace(req.ClientID)
authorizationURL := strings.TrimSpace(req.AuthorizationURL)
tokenURL := strings.TrimSpace(req.TokenURL)
if name == "" || clientID == "" || authorizationURL == "" || tokenURL == "" {
return nil, errors.New("missing required provider fields")
}
existing.Name = name
existing.Type = providerType
existing.ClientID = clientID
existing.AuthorizationURL = authorizationURL
existing.TokenURL = tokenURL
existing.UserInfoURL = strings.TrimSpace(req.UserInfoURL)
existing.Scopes = normalizeScopes(req.Scopes, providerType)
existing.IDTokenClaim = strings.TrimSpace(req.IDTokenClaim)
existing.IsActive = req.IsActive
clientSecret := strings.TrimSpace(req.ClientSecret)
if clientSecret != "" {
encrypted, err := s.encryptor.Encrypt(clientSecret)
if err != nil {
return nil, err
}
existing.ClientSecret = encrypted
}
if err := s.providerRepo.UpdateProvider(ctx, existing); err != nil {
return nil, err
}
return dto.NewAuthProviderDTO(existing), nil
}
// BuildProviderAuthorizationURL constructs a provider authorization URL. // BuildProviderAuthorizationURL constructs a provider authorization URL.
func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) { func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) {
flags, err := s.GetFeatureFlags(ctx) flags, err := s.GetFeatureFlags(ctx)
@@ -393,17 +462,7 @@ func (s *AuthService) CompleteProviderLogin(ctx context.Context, providerID bson
return nil, err return nil, err
} }
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username) return &dto.LoginResponse{User: dto.NewUserDTO(user), ExpiresIn: 3600}, nil
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 { type providerProfile struct {

View File

@@ -7,14 +7,15 @@ import (
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
) )
// CategoryService handles category operations // CategoryService handles category operations
type CategoryService struct { type CategoryService struct {
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
taskListRepo repositories.TaskListRepository
membershipRepo repositories.MembershipRepository membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
permissionService *PermissionService permissionService *PermissionService
@@ -23,12 +24,14 @@ type CategoryService struct {
// NewCategoryService creates a new category service // NewCategoryService creates a new category service
func NewCategoryService( func NewCategoryService(
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
taskListRepo repositories.TaskListRepository,
membershipRepo repositories.MembershipRepository, membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
permissionService *PermissionService, permissionService *PermissionService,
) *CategoryService { ) *CategoryService {
return &CategoryService{ return &CategoryService{
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
taskListRepo: taskListRepo,
membershipRepo: membershipRepo, membershipRepo: membershipRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
permissionService: permissionService, permissionService: permissionService,
@@ -134,6 +137,14 @@ func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entit
} }
} }
// Get task lists in this category
taskLists, err := s.taskListRepo.ListTaskListsByCategory(ctx, spaceID, category.ID)
if err == nil {
for _, taskList := range taskLists {
tree.TaskLists = append(tree.TaskLists, dto.NewTaskListDTO(taskList))
}
}
return tree, nil return tree, nil
} }

View File

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

View File

@@ -6,10 +6,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/noteapp/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
"github.com/noteapp/backend/internal/infrastructure/security" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )

View File

@@ -5,8 +5,8 @@ import (
"errors" "errors"
"strings" "strings"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )

View File

@@ -5,9 +5,9 @@ import (
"errors" "errors"
"time" "time"
"github.com/noteapp/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )
@@ -17,6 +17,7 @@ type SpaceService struct {
membershipRepo repositories.MembershipRepository membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
taskListRepo repositories.TaskListRepository
userRepo repositories.UserRepository userRepo repositories.UserRepository
permissionService *PermissionService permissionService *PermissionService
} }
@@ -27,6 +28,7 @@ func NewSpaceService(
membershipRepo repositories.MembershipRepository, membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
taskListRepo repositories.TaskListRepository,
userRepo repositories.UserRepository, userRepo repositories.UserRepository,
permissionService *PermissionService, permissionService *PermissionService,
) *SpaceService { ) *SpaceService {
@@ -35,6 +37,7 @@ func NewSpaceService(
membershipRepo: membershipRepo, membershipRepo: membershipRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
taskListRepo: taskListRepo,
userRepo: userRepo, userRepo: userRepo,
permissionService: permissionService, permissionService: permissionService,
} }
@@ -180,6 +183,9 @@ func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.Obj
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil { if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
return err return err
} }
if err := s.taskListRepo.DeleteTaskListsBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil { if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
return err return err
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,50 @@
package entities
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
const MaxTaskDepth = 2
// Task represents a task and supports up to 3 nesting levels (0,1,2).
type Task struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
Title string `bson:"title"`
Description string `bson:"description"`
TaskListID bson.ObjectID `bson:"task_list_id"`
StatusID bson.ObjectID `bson:"status_id"`
ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"`
Depth int `bson:"depth"`
NoteLinks []bson.ObjectID `bson:"note_links"`
CreatedBy bson.ObjectID `bson:"created_by"`
UpdatedBy bson.ObjectID `bson:"updated_by"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
// TaskStatus defines the ordered linear status progression for a task list.
type TaskStatus struct {
ID bson.ObjectID `bson:"_id,omitempty"`
TaskListID bson.ObjectID `bson:"task_list_id"`
Name string `bson:"name"`
Color string `bson:"color,omitempty"`
Order int `bson:"order"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
// TaskList groups tasks under a named list that can be attached to a category.
type TaskList struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
CategoryID *bson.ObjectID `bson:"category_id,omitempty"`
Name string `bson:"name"`
Description string `bson:"description,omitempty"`
CreatedBy bson.ObjectID `bson:"created_by"`
UpdatedBy bson.ObjectID `bson:"updated_by"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}

View File

@@ -3,7 +3,7 @@ package repositories
import ( import (
"context" "context"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )

View File

@@ -3,7 +3,7 @@ package repositories
import ( import (
"context" "context"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )
@@ -174,6 +174,9 @@ type AuthProviderRepository interface {
// GetAllProviders retrieves all active providers // GetAllProviders retrieves all active providers
GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error) GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error)
// GetAllProvidersForAdmin retrieves all providers, including inactive ones
GetAllProvidersForAdmin(ctx context.Context) ([]*entities.AuthProvider, error)
// UpdateProvider updates a provider // UpdateProvider updates a provider
UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error
@@ -213,3 +216,37 @@ type NoteRevisionRepository interface {
// GetRevisionByID retrieves a specific revision // GetRevisionByID retrieves a specific revision
GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error) GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error)
} }
// TaskRepository defines task operations
type TaskRepository interface {
CreateTask(ctx context.Context, task *entities.Task) error
GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error)
ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error)
SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error)
UpdateTask(ctx context.Context, task *entities.Task) error
DeleteTask(ctx context.Context, id bson.ObjectID) error
DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
}
// TaskListRepository defines task list operations.
type TaskListRepository interface {
CreateTaskList(ctx context.Context, list *entities.TaskList) error
GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error)
ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error)
ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error)
UpdateTaskList(ctx context.Context, list *entities.TaskList) error
DeleteTaskList(ctx context.Context, id bson.ObjectID) error
DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
}
// TaskStatusRepository defines task status operations
type TaskStatusRepository interface {
CreateStatus(ctx context.Context, status *entities.TaskStatus) error
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error)
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
DeleteStatus(ctx context.Context, id bson.ObjectID) error
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
}

View File

@@ -0,0 +1,114 @@
package auth
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/redis/go-redis/v9"
)
// SessionData stores authenticated identity data in Redis.
type SessionData struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
}
// SessionManager handles Redis-backed session lifecycle operations.
type SessionManager struct {
redis *redis.Client
ttl time.Duration
prefix string
}
func NewSessionManager(redisClient *redis.Client, ttl time.Duration) *SessionManager {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
return &SessionManager{
redis: redisClient,
ttl: ttl,
prefix: "session:",
}
}
func (m *SessionManager) TTL() time.Duration {
return m.ttl
}
func (m *SessionManager) CreateSession(ctx context.Context, data *SessionData) (string, error) {
if data == nil {
return "", errors.New("session data is required")
}
sessionID, err := GenerateRandomToken(32)
if err != nil {
return "", err
}
payload, err := json.Marshal(data)
if err != nil {
return "", err
}
if err := m.redis.Set(ctx, m.key(sessionID), payload, m.ttl).Err(); err != nil {
return "", err
}
return sessionID, nil
}
func (m *SessionManager) GetSession(ctx context.Context, sessionID string) (*SessionData, error) {
if sessionID == "" {
return nil, errors.New("session id is required")
}
payload, err := m.redis.Get(ctx, m.key(sessionID)).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, errors.New("session not found")
}
return nil, err
}
var data SessionData
if err := json.Unmarshal([]byte(payload), &data); err != nil {
return nil, err
}
return &data, nil
}
func (m *SessionManager) RefreshSession(ctx context.Context, sessionID string) error {
if sessionID == "" {
return errors.New("session id is required")
}
if err := m.redis.Expire(ctx, m.key(sessionID), m.ttl).Err(); err != nil {
if errors.Is(err, redis.Nil) {
return errors.New("session not found")
}
return err
}
return nil
}
func (m *SessionManager) DeleteSession(ctx context.Context, sessionID string) error {
if sessionID == "" {
return nil
}
if err := m.redis.Del(ctx, m.key(sessionID)).Err(); err != nil {
return err
}
return nil
}
func (m *SessionManager) key(sessionID string) string {
return m.prefix + sessionID
}

View File

@@ -9,7 +9,7 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options" "go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
) )
// AccountRecoveryRepository implements account recovery operations // AccountRecoveryRepository implements account recovery operations
@@ -222,6 +222,23 @@ func (r *AuthProviderRepository) GetAllProviders(ctx context.Context) ([]*entiti
return providers, nil return providers, nil
} }
// GetAllProvidersForAdmin retrieves all providers, including inactive ones
func (r *AuthProviderRepository) GetAllProvidersForAdmin(ctx context.Context) ([]*entities.AuthProvider, error) {
var providers []*entities.AuthProvider
cursor, err := r.collection.Find(ctx, bson.M{})
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 // UpdateProvider updates a provider
func (r *AuthProviderRepository) UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error { func (r *AuthProviderRepository) UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error {
provider.UpdatedAt = time.Now() provider.UpdatedAt = time.Now()

View File

@@ -16,6 +16,9 @@ type Database struct {
MembershipRepo *MembershipRepository MembershipRepo *MembershipRepository
NoteRepo *NoteRepository NoteRepo *NoteRepository
CategoryRepo *CategoryRepository CategoryRepo *CategoryRepository
TaskListRepo *TaskListRepository
TaskRepo *TaskRepository
TaskStatusRepo *TaskStatusRepository
RevisionRepo *NoteRevisionRepository RevisionRepo *NoteRevisionRepository
GroupRepo *PermissionGroupRepository GroupRepo *PermissionGroupRepository
ProviderRepo *AuthProviderRepository ProviderRepo *AuthProviderRepository
@@ -47,6 +50,9 @@ func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) {
MembershipRepo: NewMembershipRepository(db), MembershipRepo: NewMembershipRepository(db),
NoteRepo: NewNoteRepository(db), NoteRepo: NewNoteRepository(db),
CategoryRepo: NewCategoryRepository(db), CategoryRepo: NewCategoryRepository(db),
TaskListRepo: NewTaskListRepository(db),
TaskRepo: NewTaskRepository(db),
TaskStatusRepo: NewTaskStatusRepository(db),
RevisionRepo: NewNoteRevisionRepository(db), RevisionRepo: NewNoteRevisionRepository(db),
GroupRepo: NewPermissionGroupRepository(db), GroupRepo: NewPermissionGroupRepository(db),
ProviderRepo: NewAuthProviderRepository(db), ProviderRepo: NewAuthProviderRepository(db),
@@ -80,6 +86,15 @@ func (d *Database) EnsureIndexes(ctx context.Context) error {
if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil { if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil {
return err return err
} }
if err := d.TaskRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.TaskListRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.GroupRepo.EnsureIndexes(ctx); err != nil { if err := d.GroupRepo.EnsureIndexes(ctx); err != nil {
return err return err
} }

View File

@@ -6,7 +6,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options" "go.mongodb.org/mongo-driver/v2/mongo/options"

View File

@@ -9,7 +9,7 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options" "go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
) )
// NoteRepository implements the note repository interface // NoteRepository implements the note repository interface

View File

@@ -9,7 +9,7 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options" "go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
) )
// SpaceRepository implements the space repository interface // SpaceRepository implements the space repository interface

View File

@@ -0,0 +1,277 @@
package database
import (
"context"
"errors"
"time"
"gitea.hostxtra.co.uk/mrhid6/notely/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"
)
// TaskRepository implements task data access.
type TaskRepository struct {
collection *mongo.Collection
}
// NewTaskRepository creates a new task repository.
func NewTaskRepository(db *mongo.Database) *TaskRepository {
return &TaskRepository{collection: db.Collection("tasks")}
}
func (r *TaskRepository) CreateTask(ctx context.Context, task *entities.Task) error {
task.ID = bson.NewObjectID()
task.CreatedAt = time.Now()
task.UpdatedAt = time.Now()
if task.NoteLinks == nil {
task.NoteLinks = []bson.ObjectID{}
}
_, err := r.collection.InsertOne(ctx, task)
return err
}
func (r *TaskRepository) GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error) {
var task entities.Task
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&task)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("task not found")
}
return nil, err
}
return &task, nil
}
func (r *TaskRepository) ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error) {
query := bson.M{"space_id": spaceID}
for k, v := range filters {
query[k] = v
}
opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})
cursor, err := r.collection.Find(ctx, query, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var tasks []*entities.Task
if err := cursor.All(ctx, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
func (r *TaskRepository) SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error) {
cursor, err := r.collection.Find(ctx, bson.M{
"space_id": spaceID,
"$or": []bson.M{
{"title": bson.M{"$regex": query, "$options": "i"}},
{"description": bson.M{"$regex": query, "$options": "i"}},
},
}, options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetLimit(30))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var tasks []*entities.Task
if err := cursor.All(ctx, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
func (r *TaskRepository) UpdateTask(ctx context.Context, task *entities.Task) error {
task.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": task.ID}, task)
return err
}
func (r *TaskRepository) DeleteTask(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *TaskRepository) DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
return err
}
func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err
}
func (r *TaskRepository) CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) {
return r.collection.CountDocuments(ctx, bson.M{"parent_task_id": parentTaskID})
}
func (r *TaskRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "updated_at", Value: -1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "status_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "task_list_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "parent_task_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}},
})
return err
}
// TaskListRepository implements task list data access.
type TaskListRepository struct {
collection *mongo.Collection
}
// NewTaskListRepository creates a new task list repository.
func NewTaskListRepository(db *mongo.Database) *TaskListRepository {
return &TaskListRepository{collection: db.Collection("task_lists")}
}
func (r *TaskListRepository) CreateTaskList(ctx context.Context, list *entities.TaskList) error {
list.ID = bson.NewObjectID()
list.CreatedAt = time.Now()
list.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, list)
return err
}
func (r *TaskListRepository) GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error) {
var list entities.TaskList
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&list)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("task list not found")
}
return nil, err
}
return &list, nil
}
func (r *TaskListRepository) ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var lists []*entities.TaskList
if err := cursor.All(ctx, &lists); err != nil {
return nil, err
}
return lists, nil
}
func (r *TaskListRepository) ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "category_id": categoryID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var lists []*entities.TaskList
if err := cursor.All(ctx, &lists); err != nil {
return nil, err
}
return lists, nil
}
func (r *TaskListRepository) UpdateTaskList(ctx context.Context, list *entities.TaskList) error {
list.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": list.ID}, list)
return err
}
func (r *TaskListRepository) DeleteTaskList(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *TaskListRepository) DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err
}
func (r *TaskListRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}}},
})
return err
}
// TaskStatusRepository implements task status data access.
type TaskStatusRepository struct {
collection *mongo.Collection
}
// NewTaskStatusRepository creates a new task status repository.
func NewTaskStatusRepository(db *mongo.Database) *TaskStatusRepository {
return &TaskStatusRepository{collection: db.Collection("task_statuses")}
}
func (r *TaskStatusRepository) CreateStatus(ctx context.Context, status *entities.TaskStatus) error {
status.ID = bson.NewObjectID()
status.CreatedAt = time.Now()
status.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, status)
return err
}
func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error) {
var status entities.TaskStatus
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&status)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("task status not found")
}
return nil, err
}
return &status, nil
}
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error) {
cursor, err := r.collection.Find(ctx, bson.M{"task_list_id": taskListID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var statuses []*entities.TaskStatus
if err := cursor.All(ctx, &statuses); err != nil {
return nil, err
}
return statuses, nil
}
func (r *TaskStatusRepository) UpdateStatus(ctx context.Context, status *entities.TaskStatus) error {
status.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": status.ID}, status)
return err
}
func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *TaskStatusRepository) DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
return err
}
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{
Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "name", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "order", Value: 1}},
Options: options.Index().SetUnique(true),
},
})
return err
}

View File

@@ -9,7 +9,7 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options" "go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
) )
// UserRepository implements the user repository interface // UserRepository implements the user repository interface

View File

@@ -4,11 +4,12 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
) )
// AdminHandler handles admin-level HTTP requests // AdminHandler handles admin-level HTTP requests
@@ -32,6 +33,33 @@ func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{"users": users}) json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
} }
// DeleteUser handles DELETE /admin/users/{userId}
func (h *AdminHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
targetUserID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
if err != nil {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
currentUserIDHex, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
currentUserID, err := bson.ObjectIDFromHex(currentUserIDHex)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if err := h.adminService.DeleteUser(r.Context(), currentUserID, targetUserID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateUserGroups handles PUT /admin/users/{userId}/groups // UpdateUserGroups handles PUT /admin/users/{userId}/groups
func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) {
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"]) userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
@@ -66,6 +94,22 @@ func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request)
json.NewEncoder(w).Encode(user) json.NewEncoder(w).Encode(user)
} }
// DeleteGroup handles DELETE /admin/groups/{groupId}
func (h *AdminHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) {
groupID, err := bson.ObjectIDFromHex(mux.Vars(r)["groupId"])
if err != nil {
http.Error(w, "invalid group id", http.StatusBadRequest)
return
}
if err := h.adminService.DeleteGroup(r.Context(), groupID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListGroups handles GET /admin/groups // ListGroups handles GET /admin/groups
func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) {
groups, err := h.adminService.ListGroups(r.Context()) groups, err := h.adminService.ListGroups(r.Context())
@@ -292,3 +336,19 @@ func (h *AdminHandler) UpdateFeatureFlags(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(flags) json.NewEncoder(w).Encode(flags)
} }
// DeleteProvider handles DELETE /admin/auth/providers/{providerId}
func (h *AdminHandler) DeleteProvider(w http.ResponseWriter, r *http.Request) {
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
if err != nil {
http.Error(w, "invalid provider id", http.StatusBadRequest)
return
}
if err := h.adminService.DeleteProvider(r.Context(), providerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -1,32 +1,35 @@
package handlers package handlers
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"strings" "strings"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
"github.com/gorilla/mux" "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" "go.mongodb.org/mongo-driver/v2/bson"
) )
// AuthHandler handles authentication endpoints // AuthHandler handles authentication endpoints
type AuthHandler struct { type AuthHandler struct {
authService *services.AuthService authService *services.AuthService
sessionManager *auth.SessionManager
} }
// NewAuthHandler creates a new auth handler // NewAuthHandler creates a new auth handler
func NewAuthHandler(authService *services.AuthService) *AuthHandler { func NewAuthHandler(authService *services.AuthService, sessionManager *auth.SessionManager) *AuthHandler {
return &AuthHandler{ return &AuthHandler{
authService: authService, authService: authService,
sessionManager: sessionManager,
} }
} }
const sessionCookieName = "session_id"
// Register handles user registration // Register handles user registration
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
@@ -56,6 +59,11 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.setSessionCookie(w, r, response.User); err != nil {
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }
@@ -79,16 +87,10 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
// Set secure HTTP-only cookie for refresh token if err := h.setSessionCookie(w, r, response.User); err != nil {
http.SetCookie(w, &http.Cookie{ http.Error(w, "Failed to create session", http.StatusInternalServerError)
Name: "refresh_token", return
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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
@@ -96,15 +98,12 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// Logout handles user logout // Logout handles user logout
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
// Clear refresh token cookie sessionCookie, err := r.Cookie(sessionCookieName)
http.SetCookie(w, &http.Cookie{ if err == nil {
Name: "refresh_token", _ = h.sessionManager.DeleteSession(r.Context(), sessionCookie.Value)
Value: "", }
Path: "/",
MaxAge: -1, h.clearSessionCookie(w, r)
HttpOnly: true,
Secure: isSecureRequest(r),
})
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"}) json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"})
@@ -122,6 +121,18 @@ func (h *AuthHandler) ListProviders(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{"providers": providers}) json.NewEncoder(w).Encode(map[string]interface{}{"providers": providers})
} }
// ListProvidersForAdmin returns all OAuth/OIDC providers, including inactive ones.
func (h *AuthHandler) ListProvidersForAdmin(w http.ResponseWriter, r *http.Request) {
providers, err := h.authService.ListProvidersForAdmin(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. // CreateProvider stores a new OAuth/OIDC provider configuration.
func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
var req dto.CreateAuthProviderRequest var req dto.CreateAuthProviderRequest
@@ -141,6 +152,30 @@ func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(provider) json.NewEncoder(w).Encode(provider)
} }
// UpdateProvider updates an existing OAuth/OIDC provider configuration.
func (h *AuthHandler) UpdateProvider(w http.ResponseWriter, r *http.Request) {
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
if err != nil {
http.Error(w, "Invalid provider ID", http.StatusBadRequest)
return
}
var req dto.UpdateAuthProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
provider, err := h.authService.UpdateProvider(r.Context(), providerID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(provider)
}
// StartProviderLogin redirects the browser to the selected provider. // StartProviderLogin redirects the browser to the selected provider.
func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) {
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"]) providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
@@ -191,7 +226,7 @@ func (h *AuthHandler) CompleteProviderLogin(w http.ResponseWriter, r *http.Reque
response, err := h.authService.CompleteProviderLogin(r.Context(), providerID, r.URL.Query().Get("code"), buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback")) 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 { if err != nil {
http.Redirect(w, r, buildFrontendLoginURL("oauth_error", err.Error(), "", nil), http.StatusFound) http.Redirect(w, r, buildFrontendLoginURL("oauth_error", err.Error()), http.StatusFound)
return return
} }
@@ -205,17 +240,12 @@ func (h *AuthHandler) CompleteProviderLogin(w http.ResponseWriter, r *http.Reque
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
http.SetCookie(w, &http.Cookie{ if err := h.setSessionCookie(w, r, response.User); err != nil {
Name: "refresh_token", http.Redirect(w, r, buildFrontendLoginURL("oauth_error", "Failed to create session"), http.StatusFound)
Value: response.RefreshToken, return
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) http.Redirect(w, r, buildFrontendLoginURL("oauth_success", ""), http.StatusFound)
} }
// RefreshToken handles token refresh // RefreshToken handles token refresh
@@ -225,23 +255,57 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get refresh token from cookie cookie, err := r.Cookie(sessionCookieName)
cookie, err := r.Cookie("refresh_token")
if err != nil { if err != nil {
http.Error(w, "Refresh token not found", http.StatusUnauthorized) http.Error(w, "Session not found", http.StatusUnauthorized)
return return
} }
accessToken, err := h.authService.RefreshAccessToken(r.Context(), cookie.Value) sessionData, err := h.sessionManager.GetSession(r.Context(), cookie.Value)
if err != nil { if err != nil {
http.Error(w, "Invalid refresh token", http.StatusUnauthorized) http.Error(w, "Invalid session", http.StatusUnauthorized)
return return
} }
if err := h.sessionManager.RefreshSession(r.Context(), cookie.Value); err == nil {
http.SetCookie(w, h.newSessionCookie(r, cookie.Value))
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": accessToken, "user": sessionData,
"expires_in": 3600, "expires_in": int(h.sessionManager.TTL().Seconds()),
})
}
// Me returns the currently authenticated user profile.
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
sessionCookie, err := r.Cookie(sessionCookieName)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
sessionData, err := h.sessionManager.GetSession(r.Context(), sessionCookie.Value)
if err != nil {
h.clearSessionCookie(w, r)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
user, err := h.authService.GetUserProfile(r.Context(), sessionData.UserID)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := h.sessionManager.RefreshSession(r.Context(), sessionCookie.Value); err == nil {
http.SetCookie(w, h.newSessionCookie(r, sessionCookie.Value))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user": user,
"expires_in": int(h.sessionManager.TTL().Seconds()),
}) })
} }
@@ -268,7 +332,7 @@ func buildBackendURL(r *http.Request, path string) string {
return scheme + "://" + r.Host + path return scheme + "://" + r.Host + path
} }
func buildFrontendLoginURL(status, message, accessToken string, user *dto.UserDTO) string { func buildFrontendLoginURL(status, message string) string {
frontendURL := os.Getenv("FRONTEND_URL") frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" { if frontendURL == "" {
frontendURL = "http://localhost:5173" frontendURL = "http://localhost:5173"
@@ -286,14 +350,48 @@ func buildFrontendLoginURL(status, message, accessToken string, user *dto.UserDT
if message != "" { if message != "" {
query.Set("message", 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() parsed.RawQuery = query.Encode()
return parsed.String() return parsed.String()
} }
func (h *AuthHandler) setSessionCookie(w http.ResponseWriter, r *http.Request, user *dto.UserDTO) error {
if user == nil {
return nil
}
sessionID, err := h.sessionManager.CreateSession(r.Context(), &auth.SessionData{
UserID: user.ID,
Email: user.Email,
Username: user.Username,
})
if err != nil {
return err
}
http.SetCookie(w, h.newSessionCookie(r, sessionID))
return nil
}
func (h *AuthHandler) newSessionCookie(r *http.Request, sessionID string) *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
Value: sessionID,
Path: "/",
MaxAge: int(h.sessionManager.TTL().Seconds()),
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
}
}
func (h *AuthHandler) clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
}

View File

@@ -7,9 +7,9 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
"github.com/noteapp/backend/internal/interfaces/middleware" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
) )
// CategoryHandler handles category endpoints // CategoryHandler handles category endpoints

View File

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

View File

@@ -8,9 +8,9 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
"github.com/noteapp/backend/internal/interfaces/middleware" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
) )
// NoteHandler handles note endpoints // NoteHandler handles note endpoints

View File

@@ -8,8 +8,8 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
) )
// PublicHandler handles unauthenticated public read-only requests // PublicHandler handles unauthenticated public read-only requests

View File

@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/noteapp/backend/internal/application/services" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
) )
// SettingsHandler handles public app settings endpoints. // SettingsHandler handles public app settings endpoints.

View File

@@ -4,10 +4,10 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
"github.com/gorilla/mux" "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" "go.mongodb.org/mongo-driver/v2/bson"
) )

View File

@@ -0,0 +1,511 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TaskHandler handles task and task status endpoints.
type TaskHandler struct {
taskService *services.TaskService
}
// NewTaskHandler creates a task handler.
func NewTaskHandler(taskService *services.TaskService) *TaskHandler {
return &TaskHandler{taskService: taskService}
}
func parseIDsFromRequest(r *http.Request) (bson.ObjectID, bson.ObjectID, error) {
userID, err := getUserObjectID(r)
if err != nil {
return bson.NilObjectID, bson.NilObjectID, err
}
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
return bson.NilObjectID, bson.NilObjectID, err
}
return userID, spaceID, nil
}
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
var req dto.CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
task, err := h.taskService.CreateTask(r.Context(), spaceID, userID, &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(task)
}
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID := strings.TrimSpace(r.URL.Query().Get("taskListId"))
statusID := strings.TrimSpace(r.URL.Query().Get("statusId"))
parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId"))
taskListFilter := &taskListID
statusFilter := &statusID
parentFilter := &parentTaskID
if taskListID == "" {
taskListFilter = nil
}
if statusID == "" {
statusFilter = nil
}
if parentTaskID == "" {
parentFilter = nil
}
tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, taskListFilter, statusFilter, parentFilter)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
func (h *TaskHandler) ListTaskLists(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
lists, err := h.taskService.ListTaskLists(r.Context(), spaceID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(lists)
}
func (h *TaskHandler) CreateTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
var req dto.CreateTaskListRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
list, err := h.taskService.CreateTaskList(r.Context(), spaceID, userID, &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(list)
}
func (h *TaskHandler) UpdateTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.UpdateTaskListRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
list, err := h.taskService.UpdateTaskList(r.Context(), spaceID, taskListID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(list)
}
func (h *TaskHandler) DeleteTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
if err := h.taskService.DeleteTaskList(r.Context(), spaceID, taskListID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
query := r.URL.Query().Get("q")
tasks, err := h.taskService.SearchTasks(r.Context(), spaceID, userID, query)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
task, err := h.taskService.GetTaskByID(r.Context(), spaceID, taskID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
var req dto.UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
task, err := h.taskService.UpdateTask(r.Context(), spaceID, taskID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
if err := h.taskService.DeleteTask(r.Context(), spaceID, taskID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) TransitionTaskStatus(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
var req dto.TaskTransitionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
task, err := h.taskService.TransitionTaskStatus(r.Context(), spaceID, taskID, userID, req.Direction)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) LinkTaskNote(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
var req dto.LinkTaskNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(req.NoteID)
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
task, err := h.taskService.LinkNoteToTask(r.Context(), spaceID, taskID, noteID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) UnlinkTaskNote(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"])
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
task, err := h.taskService.UnlinkNoteFromTask(r.Context(), spaceID, taskID, noteID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) ListTasksByNote(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"])
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
tasks, err := h.taskService.ListTasksLinkedToNote(r.Context(), spaceID, noteID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, taskListID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(statuses)
}
func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.CreateTaskStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
status, err := h.taskService.CreateStatus(r.Context(), spaceID, taskListID, userID, &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(status)
}
func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest)
return
}
var req dto.UpdateTaskStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, taskListID, statusID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest)
return
}
if err := h.taskService.DeleteStatus(r.Context(), spaceID, taskListID, statusID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.ReorderTaskStatusesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, taskListID, userID, req.OrderedStatusIDs)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(statuses)
}

View File

@@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/noteapp/backend/internal/infrastructure/auth" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
) )
// ContextKey is a custom type for context keys // ContextKey is a custom type for context keys
@@ -20,13 +20,15 @@ const (
// AuthMiddleware verifies JWT tokens // AuthMiddleware verifies JWT tokens
type AuthMiddleware struct { type AuthMiddleware struct {
jwtManager *auth.JWTManager jwtManager *auth.JWTManager
sessionManager *auth.SessionManager
} }
// NewAuthMiddleware creates a new auth middleware // NewAuthMiddleware creates a new auth middleware
func NewAuthMiddleware(jwtManager *auth.JWTManager) *AuthMiddleware { func NewAuthMiddleware(jwtManager *auth.JWTManager, sessionManager *auth.SessionManager) *AuthMiddleware {
return &AuthMiddleware{ return &AuthMiddleware{
jwtManager: jwtManager, jwtManager: jwtManager,
sessionManager: sessionManager,
} }
} }
@@ -41,10 +43,23 @@ func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
return return
} }
// Extract token from Authorization header if sessionCookie, err := r.Cookie("session_id"); err == nil && sessionCookie.Value != "" {
sessionData, sessionErr := m.sessionManager.GetSession(r.Context(), sessionCookie.Value)
if sessionErr == nil {
_ = m.sessionManager.RefreshSession(r.Context(), sessionCookie.Value)
ctx := context.WithValue(r.Context(), UserIDKey, sessionData.UserID)
ctx = context.WithValue(ctx, EmailKey, sessionData.Email)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
return
}
}
// Fall back to Authorization header for backwards compatibility.
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
http.Error(w, "Missing authorization header", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }

View File

@@ -79,6 +79,7 @@ func CORSMiddleware(next http.Handler) http.Handler {
} }
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "600") w.Header().Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {

View File

@@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/noteapp/backend/internal/infrastructure/database" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/database"
) )
// TestDatabaseConnection tests MongoDB connection // TestDatabaseConnection tests MongoDB connection

View File

@@ -4,11 +4,11 @@ import (
"context" "context"
"testing" "testing"
"github.com/noteapp/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
"github.com/noteapp/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/infrastructure/auth" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
"github.com/noteapp/backend/internal/infrastructure/security" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )

View File

@@ -3,9 +3,6 @@ FROM node:25-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm install RUN npm install

View File

@@ -44,20 +44,6 @@ http {
listen 80; listen 80;
server_name localhost; 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 # Health check
location /health { location /health {
proxy_pass http://notely; proxy_pass http://notely;

View File

@@ -1,6 +1,17 @@
version: "3.8"
services: services:
redis:
image: redis:8-alpine
container_name: notely-redis
ports:
- "6379:6379"
networks:
- notely-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
mongodb: mongodb:
image: mongo:8.0 image: mongo:8.0
container_name: notely-mongodb container_name: notely-mongodb
@@ -25,8 +36,6 @@ services:
build: build:
context: . context: .
dockerfile: ./devops/docker/Dockerfile dockerfile: ./devops/docker/Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
container_name: notely-app container_name: notely-app
ports: ports:
- "${BACKEND_PORT}:${BACKEND_PORT}" - "${BACKEND_PORT}:${BACKEND_PORT}"
@@ -39,9 +48,15 @@ services:
DEFAULT_ADMIN_EMAIL: ${DEFAULT_ADMIN_EMAIL} DEFAULT_ADMIN_EMAIL: ${DEFAULT_ADMIN_EMAIL}
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME} DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME}
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD} DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD}
REDIS_ADDR: ${REDIS_ADDR}
REDIS_PASSWORD: ${REDIS_PASSWORD}
REDIS_DB: ${REDIS_DB}
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS}
depends_on: depends_on:
mongodb: mongodb:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
networks: networks:
- notely-network - notely-network

View File

@@ -1,8 +1,5 @@
# Frontend Environment Example # Frontend Environment Example
# API Base URL (Backend server)
VITE_API_BASE_URL=http://localhost:8080
# Environment # Environment
VITE_ENV=development VITE_ENV=development

33
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,33 @@
import js from "@eslint/js";
import globals from "globals";
import pluginVue from "eslint-plugin-vue";
export default [
{
ignores: ["dist/**", "node_modules/**"],
},
js.configs.recommended,
...pluginVue.configs["flat/essential"],
{
files: ["**/*.{js,mjs,cjs,vue}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
"no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"no-console": "off",
"vue/multi-word-component-names": "off",
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs" "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
@@ -15,14 +17,21 @@
"axios": "^1.4.0", "axios": "^1.4.0",
"bootstrap": "^5.3.0", "bootstrap": "^5.3.0",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
"highlight.js": "^11.11.1",
"marked": "^9.0.0", "marked": "^9.0.0",
"marked-highlight": "^2.2.3",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"vue": "^3.3.0", "vue": "^3.3.0",
"vue-router": "^4.2.0" "vue-router": "^4.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0",
"@vitejs/plugin-vue": "^4.2.0", "@vitejs/plugin-vue": "^4.2.0",
"@vue/test-utils": "^2.4.0", "@vue/test-utils": "^2.4.0",
"eslint": "^9.22.0",
"eslint-plugin-vue": "^9.32.0",
"globals": "^16.0.0",
"jsdom": "^29.0.1",
"vite": "^4.3.0", "vite": "^4.3.0",
"vitest": "^0.34.0" "vitest": "^0.34.0"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,106 @@
:root { :root {
--primary-color: #667eea; --color-primary: #667eea;
--secondary-color: #764ba2; --color-primary-strong: #4f46a5;
--text-color: #333; --color-text: #333333;
--bg-color: #f8f9fa; --color-text-muted: #6c757d;
--border-color: #dee2e6; --color-bg: #f8f9fa;
--color-surface: #ffffff;
--color-surface-muted: #f1f3f5;
--color-border: #dee2e6;
--color-info: #748ffc;
--color-code-bg: #353943;
--color-code-text: #f9fafb;
--color-scroll-track: #f1f1f1;
--color-scroll-thumb: #888888;
--color-scroll-thumb-hover: #555555;
--primary-color: var(--color-primary);
--secondary-color: var(--color-primary-strong);
--text-color: var(--color-text);
--bg-color: var(--color-bg);
--border-color: var(--color-border);
}
[data-bs-theme="dark"] {
--color-text: #e2e8f0;
--color-text-muted: #94a3b8;
--color-bg: #1a1d23;
--color-surface: #21252e;
--color-surface-muted: #2d3748;
--color-border: #3a3f4b;
--color-info: #7aa2f7;
--color-code-bg: #2d3748;
--color-code-text: #e2e8f0;
--color-scroll-track: #2d3748;
--color-scroll-thumb: #4a5568;
--color-scroll-thumb-hover: #718096;
--text-color: var(--color-text);
--bg-color: var(--color-bg);
--border-color: var(--color-border);
}
[data-bs-theme="dark"] body {
background-color: var(--color-bg);
color: var(--color-text);
}
[data-bs-theme="dark"] .sidebar {
background-color: var(--color-surface) !important;
border-color: var(--color-border) !important;
}
[data-bs-theme="dark"] .toolbar {
background-color: var(--color-surface);
border-color: var(--color-border) !important;
}
[data-bs-theme="dark"] .main-content {
background-color: var(--color-bg);
}
[data-bs-theme="dark"] .markdown-body table {
background: var(--color-surface);
}
[data-bs-theme="dark"] .markdown-body th {
background: var(--color-surface-muted);
}
[data-bs-theme="dark"] .markdown-body tr:nth-child(even) td {
background: #232830;
}
[data-bs-theme="dark"] .markdown-body blockquote {
background: #1e2430;
color: #a0aec0;
}
[data-bs-theme="dark"] .markdown-body :not(pre) > code {
background: var(--color-surface-muted);
color: var(--color-text);
}
[data-bs-theme="dark"] .markdown-body pre code {
background: transparent;
color: inherit;
}
[data-bs-theme="dark"] .markdown-body pre {
background: var(--color-code-bg);
color: var(--color-code-text);
}
[data-bs-theme="dark"] ::-webkit-scrollbar-track {
background: var(--color-scroll-track);
}
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb {
background: var(--color-scroll-thumb);
}
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover {
background: var(--color-scroll-thumb-hover);
} }
* { * {
@@ -25,6 +122,70 @@ body,
width: 100%; width: 100%;
} }
.markdown-body table {
width: 100%;
margin: 1rem 0;
border-collapse: collapse;
border-spacing: 0;
background: var(--color-surface);
}
.markdown-body th,
.markdown-body td {
padding: 0.7rem 0.9rem;
border: 1px solid var(--border-color);
text-align: left;
vertical-align: top;
}
.markdown-body th {
font-weight: 600;
background: #f3f6fb;
}
.markdown-body tr:nth-child(even) td {
background: #fbfcfe;
}
.markdown-body table code {
white-space: nowrap;
}
.markdown-body blockquote {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid var(--color-info);
background: #f8f9ff;
color: #334155;
}
.markdown-body blockquote > :last-child {
margin-bottom: 0;
}
.markdown-body pre {
margin: 1rem 0;
padding: 1rem;
border-radius: 0.75rem;
background: var(--color-code-bg);
color: var(--color-code-text);
overflow-x: auto;
}
.markdown-body pre code {
background: transparent;
color: inherit;
padding: 0;
}
.markdown-body code {
font-family: "Courier New", monospace;
font-size: 0.95em;
padding: 0.1rem 0.3rem;
border-radius: 0.35rem;
background: var(--color-surface-muted);
}
/* Scrollbar styling */ /* Scrollbar styling */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -32,14 +193,14 @@ body,
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #f1f1f1; background: var(--color-scroll-track);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #888; background: var(--color-scroll-thumb);
border-radius: 4px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #555; background: var(--color-scroll-thumb-hover);
} }

View File

@@ -0,0 +1,254 @@
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow-x: hidden;
overflow-y: visible;
}
.navbar {
z-index: 1100;
overflow: visible;
}
.app-navbar {
align-items: center;
}
.navbar-left {
min-width: 0;
}
.navbar-controls {
min-width: 0;
}
.app-brand {
white-space: nowrap;
}
.app-main {
flex: 1;
overflow: hidden;
}
.sidebar {
width: 280px;
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.sidebar-content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.sidebar-header {
border-bottom: 1px solid var(--color-border);
}
.main-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.content {
flex: 1;
overflow-y: auto;
}
.breadcrumb-title {
font-size: 1rem;
color: #495057;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.breadcrumb-link {
border: 0;
background: transparent;
padding: 0;
color: var(--color-primary);
text-decoration: none;
cursor: pointer;
}
.breadcrumb-link:hover {
text-decoration: underline;
}
.breadcrumb-separator {
color: var(--color-text-muted);
}
.search-box {
width: 300px;
}
.dropdown {
position: relative;
}
.dropdown-menu {
z-index: 1200;
max-width: min(92vw, 320px);
}
.dropdown-menu-end {
right: 0;
left: auto;
}
.dropdown-menu.show {
display: block;
}
.admin-route-view {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
width: 100%;
padding: 0;
}
@media (max-width: 768px) {
.app-navbar {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"left user"
"space space"
"search search";
row-gap: 0.5rem;
column-gap: 0.5rem;
align-items: center;
}
.navbar-left {
grid-area: left;
}
.navbar-controls {
width: 100%;
}
.nav-user-menu {
grid-area: user;
justify-self: end;
}
.nav-space-selector {
grid-area: space;
}
.nav-space-selector > .btn {
width: 100%;
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-search {
grid-area: search;
width: 100%;
}
.nav-search .form-control {
width: 100%;
}
.search-box {
width: 100%;
}
.app-brand {
font-size: 1.5rem;
}
.nav-menu-toggle {
padding: 0.35rem 0.55rem;
}
.sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1090;
}
.sidebar {
position: fixed;
left: 0;
bottom: 0;
width: 280px;
z-index: 1095;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
overflow-y: auto;
}
.sidebar.open {
transform: translateX(0);
}
.toolbar {
position: relative;
z-index: 0;
}
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-label {
display: none;
}
.action-button .mdi {
margin-right: 0 !important;
}
.col-auto .action-button {
min-width: 2.75rem;
}
.app-main {
flex-direction: column;
}
.main-content {
width: 100%;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .sidebar-header {
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .breadcrumb-title {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .breadcrumb-link {
color: #7aa2f7;
}
:root[data-bs-theme="dark"] .breadcrumb-separator {
color: #4a5568;
}

View File

@@ -0,0 +1,5 @@
@import "./AdminModal.shared.css";
.permissions-textarea {
font-family: "Courier New", monospace;
}

View File

@@ -0,0 +1,24 @@
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.admin-modal-backdrop {
z-index: 1990;
}
.admin-modal .modal-dialog {
margin: 1rem auto;
}
@media (max-width: 767.98px) {
.admin-modal {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.admin-modal .modal-dialog {
margin: 0.75rem;
max-width: none;
}
}

View File

@@ -0,0 +1 @@
@import "./AdminModal.shared.css";

View File

@@ -0,0 +1,2 @@
@import "./AdminModal.shared.css";

View File

@@ -0,0 +1 @@
@import "./AdminModal.shared.css";

View File

@@ -0,0 +1,213 @@
.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: var(--color-surface);
border: 1px solid var(--color-border);
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;
}
.task-list-item {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
font-size: 0.95rem;
margin-bottom: 0.25rem;
color: #2f3d52;
background: #f7f9ff;
border: 1px solid #d9e3ff;
}
.task-list-item:hover {
background: #e9efff;
}
.task-list-item span {
flex-grow: 1;
}
.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: #fff3c5;
}
.subcategories {
margin-top: 0.25rem;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .category-header:hover {
background-color: var(--color-surface-muted);
}
:root[data-bs-theme="dark"] .menu-button {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .menu-button:hover {
background-color: rgba(255, 255, 255, 0.08);
}
:root[data-bs-theme="dark"] .menu-dropdown {
background: var(--color-surface-muted);
border-color: #4a5568;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
:root[data-bs-theme="dark"] .menu-item {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .menu-item:hover {
background-color: #374151;
}
:root[data-bs-theme="dark"] .note-item:hover {
background-color: var(--color-surface-muted);
}
:root[data-bs-theme="dark"] .task-list-item {
background: #1f2a44;
border-color: #334b7d;
color: #bfceef;
}
:root[data-bs-theme="dark"] .task-list-item:hover {
background: #26365b;
}
:root[data-bs-theme="dark"] .note-item.is-pinned {
background: #1a3a5c;
border-color: #2d6a9f;
}
:root[data-bs-theme="dark"] .note-item.is-pinned:hover {
background: #1e4470;
}
:root[data-bs-theme="dark"] .note-item.is-featured {
background: #3a2e0a;
border-color: #7a5a0a;
}
:root[data-bs-theme="dark"] .note-item.is-featured:hover {
background: #453710;
}

View File

@@ -0,0 +1,12 @@
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}

View File

@@ -0,0 +1,64 @@
.file-explorer {
background: var(--color-surface);
overflow: hidden;
}
.file-explorer-header {
font-size: 0.8rem;
min-height: 36px;
}
.file-list {
max-height: 480px;
}
.file-item {
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
color: var(--color-text);
line-height: 1.3;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background-color: #f0f4ff;
}
.drag-active {
outline: 2px dashed var(--color-primary);
outline-offset: -2px;
}
.btn-delete {
opacity: 0;
transition: opacity 0.1s;
}
.file-item:hover .btn-delete {
opacity: 1;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .file-explorer {
background: var(--color-surface);
}
:root[data-bs-theme="dark"] .file-explorer-header {
background: var(--color-surface);
border-color: var(--color-border);
}
:root[data-bs-theme="dark"] .file-item {
border-bottom-color: var(--color-border);
color: var(--color-text);
}
:root[data-bs-theme="dark"] .file-item:hover {
background-color: var(--color-surface-muted);
}

View File

@@ -0,0 +1,92 @@
.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: var(--color-surface);
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%, var(--color-surface) 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;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .modal-panel {
background: var(--color-surface);
border-color: var(--color-border);
}
:root[data-bs-theme="dark"] .provider-modal-header {
background: linear-gradient(180deg, #2a2f3a 0%, var(--color-surface) 100%);
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .provider-section {
background: #2a2f3a;
border-color: var(--color-border);
}

View File

@@ -0,0 +1,267 @@
.editor-toolbar {
display: flex;
gap: 0.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
}
.save-status {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.save-status.dirty {
color: #b26a00;
}
.save-status.saving {
color: var(--color-primary);
}
.save-status.saved {
color: #2b8a3e;
}
.editor-textarea {
font-family: "Courier New", monospace;
min-height: 600px;
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 var(--color-border);
border-radius: 999px;
background: var(--color-bg);
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: var(--color-bg);
overflow-y: auto;
max-height: 600px;
}
.task-mention-panel {
margin-top: 0.45rem;
border: 1px solid #dbe4f0;
border-radius: 10px;
background: #fbfdff;
padding: 0.5rem;
max-height: 220px;
overflow-y: auto;
}
.task-mention-option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
padding: 0.35rem 0.45rem;
border-radius: 6px;
text-align: left;
}
.task-mention-option:hover {
background: #eef3ff;
}
.task-picker {
background: var(--color-surface);
min-height: 300px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-picker-header {
background: var(--color-bg);
min-height: 34px;
}
.task-picker-search {
background: var(--color-surface);
}
.task-picker-list {
overflow-y: auto;
max-height: 520px;
padding: 0.25rem;
}
.task-picker-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
border-radius: 8px;
padding: 0.35rem 0.45rem;
text-align: left;
gap: 0.4rem;
color: var(--color-text);
}
.task-picker-item:hover {
background: #eef3ff;
}
.task-picker-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-picker-empty {
padding: 0.7rem;
}
.task-picker-item small {
font-size: 0.7rem;
color: #6b7280;
}
.task-picker .btn-link {
text-decoration: none;
}
.task-picker-item {
flex-wrap: wrap;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, var(--color-surface) 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, var(--color-surface) 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .editor-toolbar {
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .flag-check {
background: var(--color-surface-muted);
border-color: #4a5568;
}
:root[data-bs-theme="dark"] .preview-pane {
background-color: var(--color-surface);
}
:root[data-bs-theme="dark"] .task-mention-panel {
border-color: #3a4558;
background: #1f2733;
}
:root[data-bs-theme="dark"] .task-mention-option:hover {
background: #2b3646;
}
:root[data-bs-theme="dark"] .task-picker {
border-color: var(--color-border) !important;
background: var(--color-surface);
}
:root[data-bs-theme="dark"] .task-picker-header {
background: var(--color-surface);
border-bottom-color: var(--color-border) !important;
}
:root[data-bs-theme="dark"] .task-picker-search {
background: var(--color-surface);
border-bottom-color: var(--color-border) !important;
}
:root[data-bs-theme="dark"] .task-picker-search .form-control {
background: #1f2430;
border-color: var(--color-border);
color: var(--color-text);
}
:root[data-bs-theme="dark"] .task-picker-item {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .task-picker-item:hover {
background: var(--color-surface-muted);
}
:root[data-bs-theme="dark"] .task-picker-item small {
color: #a8b4c7;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
}

View File

@@ -0,0 +1,168 @@
.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(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, var(--color-surface) 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, var(--color-surface) 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
.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;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .note-meta {
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .tag-chip {
background: #1e2d5f;
color: #93b4ff;
}
:root[data-bs-theme="dark"] .pinned-chip {
color: #7dd3fc;
background: #1a3a5c;
border-color: #2d6a9f;
}
:root[data-bs-theme="dark"] .featured-chip {
color: #fbbf24;
background: #3a2e0a;
border-color: #7a5a0a;
}
:root[data-bs-theme="dark"] .public-chip {
color: #67e8f9;
background: #0c2a3a;
border-color: #1d6a7a;
}
:root[data-bs-theme="dark"] .private-chip {
color: #c4b5fd;
background: #2d1f5e;
border-color: #5b3f9a;
}
:root[data-bs-theme="dark"] .protected-chip {
color: #fdba74;
background: #3a1f0a;
border-color: #7a4f1a;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
}

View File

@@ -0,0 +1,100 @@
.search-results-page {
max-width: 1200px;
margin: 0 auto;
}
.search-results-header {
margin-bottom: 1.5rem;
}
.search-results-header h2 {
margin: 0;
font-size: 1.5rem;
color: #223149;
}
.search-meta {
margin: 0.35rem 0 0;
color: #5b6f8b;
}
.pagination-bar {
margin-top: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.85rem;
}
.page-indicator {
color: #4f637d;
font-weight: 600;
}
.empty-state {
min-height: 48vh;
border: 1px dashed #cfdae9;
border-radius: 14px;
background: radial-gradient(circle at 20% 20%, #f2f9ff 0%, #edf2ff 70%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem 1rem;
}
.empty-state-icon {
font-size: 4.2rem;
color: #60789a;
margin-bottom: 0.6rem;
}
.empty-state h3 {
margin: 0;
color: #223149;
}
.empty-state p {
margin: 0.6rem 0 0;
color: #5b6f8b;
max-width: 500px;
}
@media (max-width: 768px) {
.pagination-bar {
flex-direction: column;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .search-results-header h2 {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .search-meta {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .page-indicator {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .empty-state {
border-color: var(--color-border);
background: radial-gradient(circle at 20% 20%, #1a2035 0%, #1e2430 70%);
}
:root[data-bs-theme="dark"] .empty-state h3 {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .empty-state p {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .empty-state-icon {
color: #4a6fa5;
}

View File

@@ -0,0 +1,383 @@
.task-board {
display: flex;
flex-direction: column;
gap: 1rem;
}
.task-board-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.task-filters {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.status-lane {
border: 1px solid #d9e2ec;
border-radius: 12px;
padding: 0.75rem;
background: linear-gradient(180deg, #fcfdff 0%, #f5f8fc 100%);
}
.lane-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.status-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.45rem;
border-radius: 8px;
background: var(--color-surface);
border: 1px solid #e4e9f0;
cursor: grab;
}
.status-item.is-drag-over {
border-color: #7aa2f7;
background: #eef3ff;
}
.drag-handle {
color: #74839a;
display: inline-flex;
align-items: center;
justify-content: center;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-name {
flex: 1;
font-weight: 600;
}
.status-actions {
display: inline-flex;
gap: 0.35rem;
}
.task-status-groups {
display: flex;
flex-direction: column;
gap: 1rem;
}
.status-group {
border: 1px solid #dbe4f0;
border-radius: 12px;
overflow: visible;
background: var(--color-surface);
}
.status-group-header {
display: flex;
align-items: center;
justify-content: space-between;
border-left: 6px solid transparent;
background: #f8fbff;
border-bottom: 1px solid #edf2f8;
padding: 0.65rem 0.85rem;
}
.status-group-title-wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.status-group-title {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.status-group-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-group-count {
color: #5f6f87;
font-weight: 600;
}
.status-empty {
padding: 0.75rem 0.85rem;
color: #7a8799;
font-size: 0.9rem;
}
.task-tree-row {
border-bottom: 1px solid #edf2f8;
}
.task-tree-row:last-child {
border-bottom: 0;
}
.task-tree-row.level-1 .task-row {
padding-left: 2.1rem;
}
.task-tree-row.level-2 .task-row {
padding-left: 3.5rem;
}
.task-row {
width: 100%;
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 0.65rem;
align-items: center;
border: 0;
background: var(--color-surface);
text-align: left;
padding: 0.7rem 0.85rem;
}
.task-row:hover {
background: #f4f8ff;
}
.status-group-header {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.status-group > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.tree-toggle {
width: 1.25rem;
color: #5f6f87;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
padding: 0;
}
.task-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.task-main strong,
.task-main small {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-status-menu {
position: relative;
display: inline-flex;
}
.status-trigger {
width: 28px;
height: 28px;
border: 0;
background: transparent;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.status-trigger:hover {
background: #eef3f9;
}
.status-trigger-dot {
width: 14px;
height: 14px;
border: 2px solid var(--color-surface);
box-shadow: 0 0 0 1px rgba(67, 81, 98, 0.25);
border-radius: 999px;
}
.status-popup {
position: absolute;
right: 0;
top: calc(100% + 0.3rem);
min-width: 190px;
background: #151a22;
border: 1px solid #2a3343;
border-radius: 10px;
box-shadow: 0 12px 28px rgba(5, 9, 15, 0.35);
padding: 0.35rem;
z-index: 40;
}
.status-option {
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
color: #e8edf5;
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.5rem;
text-align: left;
}
.status-option:hover,
.status-option.selected {
background: rgba(255, 255, 255, 0.09);
}
.status-option-dot {
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid;
background: transparent;
}
.status-option-label {
font-size: 0.86rem;
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 600;
}
.status-option-check {
color: #e8edf5;
font-size: 0.95rem;
}
.empty-state {
padding: 1rem;
color: var(--color-text-muted);
}
.status-color-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem;
align-items: center;
}
@media (max-width: 900px) {
.task-filters {
grid-template-columns: 1fr;
}
.task-row {
grid-template-columns: 24px minmax(0, 1fr) auto;
gap: 0.5rem;
}
.status-popup {
right: -0.2rem;
min-width: 170px;
}
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .status-lane {
background: linear-gradient(180deg, #1e2330 0%, #1a1d27 100%);
border-color: var(--color-border);
}
:root[data-bs-theme="dark"] .status-item {
background: #252b38;
border-color: var(--color-border);
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-item.is-drag-over {
border-color: #7aa2f7;
background: #1e2d4a;
}
:root[data-bs-theme="dark"] .drag-handle {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .status-group {
background: #1e2230;
border-color: var(--color-border);
}
:root[data-bs-theme="dark"] .status-group-header {
background: #232840;
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .status-group-title {
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-group-count {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-empty {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .task-tree-row {
border-bottom-color: #2e3444;
}
:root[data-bs-theme="dark"] .task-row {
background: #1e2230;
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .task-row:hover {
background: #252d40;
}
:root[data-bs-theme="dark"] .tree-toggle {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .task-main small {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-trigger:hover {
background: #2e3448;
}
:root[data-bs-theme="dark"] .status-trigger-dot {
border-color: #1e2230;
box-shadow: 0 0 0 1px rgba(180, 195, 220, 0.2);
}
:root[data-bs-theme="dark"] .empty-state {
color: #7a8fa8;
}

View File

@@ -0,0 +1,61 @@
.status-progress {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.progress-step {
display: flex;
align-items: center;
gap: 0.45rem;
color: #627086;
}
.progress-step.current {
color: #0f172a;
font-weight: 700;
}
.progress-step.done {
color: #1f7a4d;
}
.dot {
width: 12px;
height: 12px;
border-radius: 999px;
border: 2px solid;
}
.subtask-row {
width: 100%;
margin-top: 0.35rem;
border: 1px solid #dbe4f0;
border-radius: 8px;
background: #f8fbff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0.5rem;
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .progress-step {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .progress-step.current {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .progress-step.done {
color: #4ade80;
}
:root[data-bs-theme="dark"] .subtask-row {
background: #252b38;
border-color: var(--color-border);
color: #c8d3e6;
}

View File

@@ -0,0 +1,220 @@
.workspace-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.empty-workspace-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-workspace-icon {
font-size: 5.25rem;
line-height: 1;
color: #60789a;
margin-bottom: 0.85rem;
}
.empty-workspace-title {
margin: 0;
color: #23364f;
font-size: 1.8rem;
font-weight: 700;
}
.empty-workspace-message {
margin: 0.75rem 0 0;
max-width: 460px;
color: #4f637d;
font-size: 1.05rem;
}
.content-card {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
background: var(--color-surface);
}
.content-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.content-title {
margin-bottom: 0.5rem;
color: var(--color-text);
display: flex;
align-items: center;
gap: 0.3rem;
}
.content-preview {
color: #666;
margin-bottom: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pin-icon {
color: #408aca;
font-size: 0.9em;
flex-shrink: 0;
}
.featured-icon {
color: #f08c00;
font-size: 0.95em;
flex-shrink: 0;
}
.list-icon {
color: #5568a8;
font-size: 1rem;
flex-shrink: 0;
}
.content-card.is-pinned {
background: #dbf5ff;
border-color: #a8d1ff;
}
.content-card.is-featured {
border-color: #ffd8a8;
background: #fff9db;
}
.content-card.is-task-list {
border-color: #d9e3ff;
background: #f7f9ff;
}
.list-footer {
grid-column: 1 / -1;
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.workspace-list--list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.workspace-list--list .content-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
border-radius: 6px;
}
.workspace-list--list .content-card:hover {
transform: none;
box-shadow: none;
background-color: #eef2ff;
border-color: var(--color-primary);
border-left: 3px solid var(--color-primary);
}
.workspace-list--list .content-title {
flex: 0 0 220px;
margin-bottom: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-list--list .content-preview {
flex: 1;
margin-bottom: 0;
}
.workspace-list--list .content-card > small {
flex: 0 0 auto;
white-space: nowrap;
}
@media (max-width: 768px) {
.empty-workspace-state {
min-height: 40vh;
padding: 1.5rem 1rem;
}
.empty-workspace-icon {
font-size: 4.3rem;
}
.empty-workspace-title {
font-size: 1.45rem;
}
}
:root[data-bs-theme="dark"] .empty-workspace-state {
border-color: var(--color-border);
background: linear-gradient(180deg, #1e2430 0%, var(--color-surface) 100%);
}
:root[data-bs-theme="dark"] .empty-workspace-title {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .empty-workspace-message {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .content-card {
border-color: var(--color-border);
background-color: var(--color-surface);
}
:root[data-bs-theme="dark"] .content-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
:root[data-bs-theme="dark"] .workspace-list--list .content-card:hover {
background-color: #2a2f3a;
border-color: #7aa2f7;
border-left-color: #7aa2f7;
}
:root[data-bs-theme="dark"] .content-title {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .content-preview {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .content-card.is-pinned {
background: #1a3a5c;
border-color: #2d6a9f;
}
:root[data-bs-theme="dark"] .content-card.is-featured {
background: #3a2e0a;
border-color: #7a5a0a;
}
:root[data-bs-theme="dark"] .content-card.is-task-list {
background: #1f2a44;
border-color: #334b7d;
}
:root[data-bs-theme="dark"] .list-icon {
color: #bfceef;
}

View File

@@ -0,0 +1,234 @@
.admin-page {
width: 100%;
max-width: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.admin-topbar {
flex-wrap: wrap;
padding: 1rem;
border-bottom: 1px solid var(--color-border);
}
.admin-shell {
display: flex;
flex: 1;
min-height: 0;
gap: 0;
overflow: hidden;
}
.admin-sidebar {
width: 280px;
flex-shrink: 0;
background: var(--color-bg);
border-right: 1px solid var(--color-border);
}
.admin-sidebar-inner {
padding: 0.75rem;
}
.admin-nav .nav-link {
border-radius: 0.6rem;
color: #495057;
font-weight: 500;
}
.admin-nav .nav-link:hover {
background: #eef2f7;
color: #212529;
}
.admin-nav .nav-link.active {
background: #212529;
color: var(--color-surface);
}
.admin-content {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 1rem;
}
.admin-section {
border-radius: 12px;
}
.users-list .list-group-item {
padding: 1rem;
}
.user-row {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
}
.user-row-main {
flex: 1;
min-width: 0;
}
.user-row-actions {
flex-shrink: 0;
}
.user-actions-stack {
flex-wrap: wrap;
justify-content: flex-end;
}
.user-name-line {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.user-name {
font-size: 1.1rem;
}
.user-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem 1.25rem;
}
.user-meta-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin-bottom: 0.1rem;
}
.user-meta-value {
color: #495057;
overflow-wrap: anywhere;
}
.user-meta-item-groups {
grid-column: span 1;
}
@media (max-width: 991.98px) {
.user-meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.user-meta-item-groups {
grid-column: 1 / -1;
}
}
@media (max-width: 767.98px) {
.admin-shell {
display: block;
min-height: auto;
}
.admin-topbar {
padding: 0.75rem;
}
.admin-content {
padding: 0.75rem;
}
.admin-sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1400;
}
.admin-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(82vw, 320px);
z-index: 1410;
transform: translateX(-100%);
transition: transform 0.25s ease;
border-right: 1px solid var(--color-border);
}
.admin-sidebar-inner {
padding: 0.75rem;
}
.admin-sidebar.open {
transform: translateX(0);
}
.user-row {
flex-direction: column;
align-items: stretch;
}
.user-row-actions {
width: 100%;
}
.user-row-actions .btn {
width: 100%;
}
.user-actions-stack {
flex-direction: column;
}
.user-meta-grid {
grid-template-columns: 1fr;
gap: 0.65rem;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .admin-topbar {
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .admin-sidebar {
background: var(--color-surface);
border-right-color: var(--color-border);
}
:root[data-bs-theme="dark"] .admin-nav .nav-link {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .admin-nav .nav-link:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
:root[data-bs-theme="dark"] .admin-nav .nav-link.active {
background: var(--color-text);
color: #1a1d23;
}
:root[data-bs-theme="dark"] .user-meta-value {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .admin-section {
background-color: var(--color-surface);
}

View File

@@ -0,0 +1,116 @@
.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: var(--color-surface);
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: var(--color-text-muted);
font-size: 0.9rem;
}
.oauth-divider::before,
.oauth-divider::after {
content: "";
flex: 1;
border-bottom: 1px solid var(--color-border);
}
.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;
}
}

View File

@@ -0,0 +1,71 @@
.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: var(--color-surface)4e6;
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);
}
}

View File

@@ -0,0 +1,93 @@
.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: var(--color-surface);
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;
}
}

View File

@@ -0,0 +1,32 @@
.danger-zone {
padding: 1rem;
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: #fff5f5;
}
.danger-zone-title {
color: #9f1c1c;
font-size: 1rem;
font-weight: 700;
margin: 0;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
margin-bottom: 0;
}
:root[data-bs-theme="dark"] .danger-zone {
background: #2d1a1a;
border-color: #7a3030;
}
:root[data-bs-theme="dark"] .danger-zone-title {
color: #fc8181;
}
:root[data-bs-theme="dark"] .danger-zone-copy {
color: #fca5a5;
}

View File

@@ -0,0 +1,99 @@
<template>
<teleport to="body">
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ mode === "create" ? "Create Group" : "Edit Group" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<form @submit.prevent="handleSubmit">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Group name</label>
<input v-model="form.name" class="form-control" type="text" required :disabled="isSystemGroup" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input v-model="form.description" class="form-control" type="text" :disabled="isSystemGroup" />
</div>
<div>
<label class="form-label">Permissions (one per line)</label>
<textarea
v-model="form.permissionsText"
class="form-control permissions-textarea"
rows="10"
placeholder="space.create&#10;space.project_docs.category.create&#10;space.project_docs.*"
:disabled="isSystemGroup"
></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
<button v-if="!isSystemGroup" type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Saving..." : mode === "create" ? "Create Group" : "Save Changes" }}
</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
</teleport>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
mode: {
type: String,
default: "create",
},
group: {
type: Object,
default: null,
},
isSystemGroup: {
type: Boolean,
default: false,
},
submitting: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "submit"]);
const form = ref({
name: "",
description: "",
permissionsText: "",
});
const hydrateForm = () => {
form.value = {
name: props.group?.name || "",
description: props.group?.description || "",
permissionsText: (props.group?.permissions || []).join("/n"),
};
};
watch(() => [props.mode, props.group], hydrateForm, { immediate: true });
const handleSubmit = () => {
emit("submit", {
name: form.value.name,
description: form.value.description,
permissionsText: form.value.permissionsText,
});
};
</script>
<style scoped src="../assets/styles/scoped/components/AdminGroupModal.css"></style>

View File

@@ -0,0 +1,182 @@
<template>
<teleport to="body">
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ mode === "create" ? "Add Identity Provider" : "Edit Identity Provider" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<form @submit.prevent="handleSubmit">
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Display Name <span class="text-danger">*</span></label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Provider Type <span class="text-danger">*</span></label>
<select v-model="form.type" class="form-select">
<option value="oidc">OIDC</option>
<option value="oauth2">OAuth2</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Client ID <span class="text-danger">*</span></label>
<input v-model="form.client_id" type="text" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">
Client Secret
<span v-if="mode === 'create'" class="text-danger">*</span>
<span v-else class="text-muted small">(leave blank to keep existing)</span>
</label>
<input v-model="form.client_secret" type="password" class="form-control" :required="mode === 'create'" autocomplete="new-password" />
</div>
<div class="col-md-6">
<label class="form-label">Authorization URL <span class="text-danger">*</span></label>
<input v-model="form.authorization_url" type="url" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Token URL <span class="text-danger">*</span></label>
<input v-model="form.token_url" type="url" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">UserInfo URL</label>
<input v-model="form.userinfo_url" type="url" class="form-control" placeholder="Optional" />
</div>
<div class="col-md-6">
<label class="form-label">ID Token Claim</label>
<input v-model="form.id_token_claim" type="text" class="form-control" placeholder="id_token" />
</div>
<div class="col-12">
<label class="form-label">Scopes</label>
<input v-model="form.scopes" type="text" class="form-control" placeholder="openid, profile, email" />
<div class="form-text">Comma-separated list of OAuth scopes.</div>
</div>
<div class="col-12">
<div class="form-check">
<input id="provider-active" v-model="form.is_active" type="checkbox" class="form-check-input" />
<label for="provider-active" class="form-check-label">Provider is active</label>
</div>
</div>
<div v-if="mode === 'edit'" class="col-12">
<DangerZonePanel
class="mt-4"
title-id="danger-zone-title"
title="Danger Zone"
description="Permanently delete this provider configuration. This action cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Provider
</button>
</DangerZonePanel>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Saving..." : mode === "create" ? "Add Provider" : "Save Changes" }}
</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
</teleport>
</template>
<script setup>
import { ref, watch } from "vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({
mode: {
type: String,
default: "create",
},
provider: {
type: Object,
default: null,
},
submitting: {
type: Boolean,
default: false,
},
deleting: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "submit", "delete"]);
const form = ref({
name: "",
type: "oidc",
client_id: "",
client_secret: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
id_token_claim: "id_token",
scopes: "openid, profile, email",
is_active: true,
});
const hydrateForm = () => {
if (props.mode === "edit" && props.provider) {
form.value = {
name: props.provider.name || "",
type: props.provider.type || "oidc",
client_id: props.provider.client_id || "",
client_secret: "",
authorization_url: props.provider.authorization_url || "",
token_url: props.provider.token_url || "",
userinfo_url: props.provider.userinfo_url || "",
id_token_claim: props.provider.id_token_claim || "id_token",
scopes: (props.provider.scopes || []).join(", "),
is_active: props.provider.is_active ?? true,
};
} else {
form.value = {
name: "",
type: "oidc",
client_id: "",
client_secret: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
id_token_claim: "id_token",
scopes: "openid, profile, email",
is_active: true,
};
}
};
watch(() => [props.mode, props.provider], hydrateForm, { immediate: true });
const handleSubmit = () => {
emit("submit", {
name: form.value.name,
type: form.value.type,
client_id: form.value.client_id,
client_secret: form.value.client_secret,
authorization_url: form.value.authorization_url,
token_url: form.value.token_url,
userinfo_url: form.value.userinfo_url,
id_token_claim: form.value.id_token_claim,
scopes: form.value.scopes
.split(",")
.map((s) => s.trim())
.filter(Boolean),
is_active: form.value.is_active,
});
};
</script>
<style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<teleport to="body"> <teleport v-if="!showDeleteConfirmModal" to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-xl modal-dialog-centered" role="document"> <div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Edit Space</h5> <h5 class="modal-title">Edit Space</h5>
@@ -85,24 +85,39 @@
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div> <div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
<hr /> <hr />
<div class="border border-danger rounded p-3 mt-3"> <DangerZonePanel
<h6 class="text-danger mb-1">Danger Zone</h6> class="mt-4"
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p> title-id="danger-zone-title"
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace"> title="Danger Zone"
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
{{ deleting ? "Deleting..." : "Delete Space" }} {{ deleting ? "Deleting..." : "Delete Space" }}
</button> </button>
</div> </DangerZonePanel>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop fade show admin-modal-backdrop"></div>
</teleport> </teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
space: { space: {
@@ -133,6 +148,20 @@ const error = ref("");
const success = ref(""); const success = ref("");
const newMember = ref({ user_id: "" }); const newMember = ref({ user_id: "" });
const deleting = ref(false); const deleting = ref(false);
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "member") {
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
return `Remove member "${memberName}" from this space?`;
}
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
});
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-"); const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
@@ -208,9 +237,20 @@ const addMember = async () => {
} }
}; };
const removeMember = async (member) => { const removeMember = (member) => {
const memberName = member?.username || member?.user_id; if (!member?.user_id) {
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) { return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return; return;
} }
@@ -236,10 +276,15 @@ watch(
{ immediate: true }, { immediate: true },
); );
const deleteSpace = async () => { const requestDeleteSpace = () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) { deleteConfirmIntent.value = {
return; type: "space",
} payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true; deleting.value = true;
clearMessages(); clearMessages();
try { try {
@@ -247,8 +292,51 @@ const deleteSpace = async () => {
emit("deleted", props.space); emit("deleted", props.space);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete space."; error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
}; };
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "member") {
await removeMemberConfirmed(payload);
} else if (type === "space") {
await deleteSpaceConfirmed();
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
</script> </script>
<style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>

View File

@@ -0,0 +1,89 @@
<template>
<teleport to="body">
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit User</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<form @submit.prevent="handleSubmit">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Username</label>
<input class="form-control" :value="user?.username || ''" type="text" disabled />
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input class="form-control" :value="user?.email || ''" type="text" disabled />
</div>
<div class="mb-3">
<label class="form-label">Status</label>
<input class="form-control" :value="user?.is_active ? 'Active' : 'Inactive'" type="text" disabled />
</div>
<div>
<label class="form-label">Groups</label>
<select v-model="groupIds" class="form-select" multiple>
<option v-for="group in groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
<div class="small text-muted mt-1">Ctrl/Cmd+Click for multiple groups</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Saving..." : "Save Changes" }}
</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
</teleport>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
user: {
type: Object,
default: null,
},
groups: {
type: Array,
default: () => [],
},
submitting: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "submit"]);
const groupIds = ref([]);
watch(
() => props.user,
(user) => {
groupIds.value = [...(user?.group_ids || [])];
},
{ immediate: true },
);
const handleSubmit = () => {
emit("submit", { group_ids: groupIds.value });
};
</script>
<style scoped src="../assets/styles/scoped/components/AdminUserModal.css"></style>

View File

@@ -2,7 +2,7 @@
<div class="category-tree"> <div class="category-tree">
<div v-for="category in categories" :key="category.id" class="category-item"> <div v-for="category in categories" :key="category.id" class="category-item">
<div class="category-header" @click="handleCategoryClick(category)"> <div class="category-header" @click="handleCategoryClick(category)">
<span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length"> <span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length || category.task_lists?.length">
<i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i> <i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i>
</span> </span>
<span v-else class="expand-icon"> </span> <span v-else class="expand-icon"> </span>
@@ -20,6 +20,11 @@
</div> </div>
<div v-if="expandedCategories[category.id]" class="category-content"> <div v-if="expandedCategories[category.id]" class="category-content">
<div v-for="taskList in category.task_lists || []" :key="taskList.id" class="task-list-item" @click.stop="onSelectTaskList(taskList)">
<i class="mdi mdi-format-list-checkbox me-1" aria-hidden="true"></i>
<span>{{ taskList.name }}</span>
</div>
<div <div
v-for="note in sortedNotes(category.notes)" v-for="note in sortedNotes(category.notes)"
:key="note.id" :key="note.id"
@@ -41,6 +46,7 @@
:on-add-subcategory="onAddSubcategory" :on-add-subcategory="onAddSubcategory"
:on-edit-category="onEditCategory" :on-edit-category="onEditCategory"
:on-delete-category="onDeleteCategory" :on-delete-category="onDeleteCategory"
:on-select-task-list="onSelectTaskList"
:can-create-categories="canCreateCategories" :can-create-categories="canCreateCategories"
:can-edit-categories="canEditCategories" :can-edit-categories="canEditCategories"
:can-delete-categories="canDeleteCategories" :can-delete-categories="canDeleteCategories"
@@ -80,6 +86,10 @@ const props = defineProps({
type: Function, type: Function,
required: true, required: true,
}, },
onSelectTaskList: {
type: Function,
required: true,
},
canCreateCategories: { canCreateCategories: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -141,138 +151,4 @@ const handleDeleteCategory = (category) => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/CategoryTree.css"></style>
.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,62 @@
<template>
<teleport to="body">
<div v-if="visible" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<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-alert-outline text-danger" aria-hidden="true"></i>
<span>{{ title }}</span>
</h5>
<button type="button" class="btn-close" aria-label="Close" :disabled="busy" @click="emit('close')"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-0">{{ message }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" :disabled="busy" @click="emit('close')">{{ cancelLabel }}</button>
<button type="button" class="btn btn-danger" :disabled="busy" @click="emit('confirm')">
{{ busy ? busyLabel : confirmLabel }}
</button>
</div>
</div>
</div>
</div>
<div v-if="visible" class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
defineProps({
visible: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "Confirm Deletion",
},
message: {
type: String,
default: "Are you sure you want to continue?",
},
confirmLabel: {
type: String,
default: "Delete",
},
cancelLabel: {
type: String,
default: "Cancel",
},
busyLabel: {
type: String,
default: "Deleting...",
},
busy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "confirm"]);
</script>

View File

@@ -90,4 +90,5 @@ const handleSubmit = () => {
}; };
</script> </script>
<style scoped></style>

View File

@@ -141,16 +141,7 @@ const handleCreate = () => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/CreateNoteModal.css"></style>
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
</style>

View File

@@ -49,4 +49,5 @@ const handleCreate = () => {
}; };
</script> </script>
<style scoped></style>

View File

@@ -0,0 +1,84 @@
<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">Create New Task List</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="taskListName" class="form-label">Task List Name</label>
<input id="taskListName" v-model="form.name" type="text" class="form-control" maxlength="120" required />
</div>
<div class="mb-3">
<label for="taskListCategory" class="form-label">Category</label>
<select id="taskListCategory" v-model="form.category_id" class="form-select">
<option :value="null">No category</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
</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, watch } from "vue";
const props = defineProps({
categoryOptions: {
type: Array,
default: () => [],
},
defaultCategoryId: {
type: String,
default: null,
},
});
const emit = defineEmits(["close", "create"]);
const form = ref({
name: "",
category_id: null,
});
watch(
() => props.defaultCategoryId,
(defaultCategoryId) => {
form.value.category_id = defaultCategoryId || null;
},
{ immediate: true },
);
const closeModal = () => {
emit("close");
};
const handleCreate = () => {
const name = form.value.name.trim();
if (!name) {
return;
}
emit("create", {
name,
category_id: form.value.category_id || null,
});
form.value = {
name: "",
category_id: props.defaultCategoryId || null,
};
};
</script>

View File

@@ -0,0 +1,24 @@
<template>
<section class="danger-zone" :aria-labelledby="titleId">
<h3 :id="titleId" class="danger-zone-title mb-2">{{ title }}</h3>
<p class="danger-zone-copy mb-3">{{ description }}</p>
<slot></slot>
</section>
</template>
<script setup>
defineProps({
titleId: {
type: String,
required: true,
},
title: {
type: String,
default: "Danger Zone",
},
description: {
type: String,
default: "This action is permanent and cannot be undone.",
},
});
</script>

View File

@@ -0,0 +1,317 @@
<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">Edit Task List</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<div class="modal-body">
<!-- Task List Details -->
<div class="mb-4">
<label class="form-label" for="editTaskListName">Name</label>
<input id="editTaskListName" v-model="listForm.name" type="text" class="form-control" maxlength="120" />
<label class="form-label mt-3" for="editTaskListCategory">Category</label>
<select id="editTaskListCategory" v-model="listForm.category_id" class="form-select">
<option :value="null">No category</option>
<option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">{{ cat.label }}</option>
</select>
<button type="button" class="btn btn-primary mt-3" @click="saveListDetails">Save Details</button>
</div>
<hr />
<!-- Status Progression -->
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Status Progression</strong>
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
</div>
<div class="status-list">
<div
v-for="status in statuses"
:key="status.id"
class="status-item"
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
draggable="true"
@dragstart="onStatusDragStart(status.id)"
@dragover.prevent="onStatusDragOver(status.id)"
@dragleave="onStatusDragLeave(status.id)"
@drop.prevent="onStatusDrop(status.id)"
@dragend="onStatusDragEnd"
>
<span class="drag-handle" aria-hidden="true">
<i class="mdi mdi-drag-horizontal-variant"></i>
</span>
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
<span class="status-name">{{ status.name }}</span>
<div class="status-actions">
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
</div>
</div>
</div>
</div>
<hr />
<!-- Danger Zone -->
<DangerZonePanel
v-if="canDeleteTaskList"
title-id="edit-task-list-danger-zone"
title="Danger Zone"
description="Delete this task list, all associated tasks, and statuses permanently."
>
<button type="button" class="btn btn-danger" @click="emit('delete-task-list', taskList)">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Task List
</button>
</DangerZonePanel>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
<!-- Status Create/Edit Sub-Modal -->
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" style="z-index: 1060" @click.self="closeStatusModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
</div>
<div class="modal-body">
<label class="form-label" for="editStatusName">Status Name</label>
<input id="editStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
<label class="form-label mt-3" for="editStatusColor">Status Color</label>
<div class="status-color-row">
<input id="editStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
</div>
<DangerZonePanel
v-if="statusMode === 'edit'"
class="mt-4"
title-id="edit-status-danger-zone"
title="Danger Zone"
description="Deleting this status is permanent and cannot be undone."
>
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
</DangerZonePanel>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
<button type="button" class="btn btn-primary" @click="submitStatusForm">
{{ statusMode === "create" ? "Create" : "Save" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="showStatusModal" class="modal-backdrop fade show" style="z-index: 1055"></div>
</teleport>
</template>
<script setup>
import { ref, watch } from "vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({
taskList: {
type: Object,
required: true,
},
statuses: {
type: Array,
default: () => [],
},
categoryOptions: {
type: Array,
default: () => [],
},
canDeleteTaskList: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "update-task-list", "reorder-status", "create-status", "rename-status", "delete-status", "delete-task-list"]);
const listForm = ref({ name: "", category_id: null });
watch(
() => props.taskList,
(tl) => {
listForm.value = {
name: tl?.name || "",
category_id: tl?.category_id || null,
};
},
{ immediate: true },
);
const saveListDetails = () => {
const name = listForm.value.name?.trim();
if (!name) {
return;
}
emit("update-task-list", {
name,
category_id: listForm.value.category_id || null,
});
};
// Status drag-and-drop reorder
const draggedStatusId = ref("");
const dragOverStatusId = ref("");
const onStatusDragStart = (id) => {
draggedStatusId.value = id;
};
const onStatusDragOver = (id) => {
dragOverStatusId.value = id;
};
const onStatusDragLeave = (id) => {
if (dragOverStatusId.value === id) {
dragOverStatusId.value = "";
}
};
const onStatusDrop = (targetId) => {
if (!draggedStatusId.value || draggedStatusId.value === targetId) {
onStatusDragEnd();
return;
}
const ordered = props.statuses.map((s) => s.id);
const fromIndex = ordered.indexOf(draggedStatusId.value);
const targetIndex = ordered.indexOf(targetId);
if (fromIndex < 0 || targetIndex < 0) {
onStatusDragEnd();
return;
}
ordered.splice(fromIndex, 1);
const insertIndex = ordered.indexOf(targetId);
ordered.splice(insertIndex, 0, draggedStatusId.value);
emit("reorder-status", ordered);
onStatusDragEnd();
};
const onStatusDragEnd = () => {
draggedStatusId.value = "";
dragOverStatusId.value = "";
};
// Status create/edit modal
const showStatusModal = ref(false);
const statusMode = ref("create");
const editingStatusId = ref("");
const statusForm = ref({ name: "", color: "#7c8596" });
const openCreateStatusModal = () => {
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = { name: "", color: "#7c8596" };
showStatusModal.value = true;
};
const openEditStatusModal = (status) => {
statusMode.value = "edit";
editingStatusId.value = status.id;
statusForm.value = { name: status.name || "", color: status.color || "#7c8596" };
showStatusModal.value = true;
};
const closeStatusModal = () => {
showStatusModal.value = false;
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = { name: "", color: "#7c8596" };
};
const submitStatusForm = () => {
const name = statusForm.value.name?.trim();
if (!name) {
return;
}
const color = statusForm.value.color?.trim() || "";
if (statusMode.value === "create") {
emit("create-status", { name, color });
} else if (editingStatusId.value) {
emit("rename-status", { id: editingStatusId.value, name, color });
}
closeStatusModal();
};
const deleteStatusFromModal = () => {
if (statusMode.value !== "edit" || !editingStatusId.value) {
return;
}
emit("delete-status", {
id: editingStatusId.value,
name: statusForm.value.name?.trim() || "",
color: statusForm.value.color?.trim() || "",
});
closeStatusModal();
};
</script>
<style scoped>
.status-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--bs-border-color);
background: var(--bs-body-bg);
cursor: grab;
}
.status-item.is-drag-over {
border-color: var(--bs-primary);
background: rgba(var(--bs-primary-rgb), 0.08);
}
.drag-handle {
cursor: grab;
opacity: 0.5;
font-size: 1.1rem;
line-height: 1;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-name {
flex: 1;
font-size: 0.9rem;
}
.status-actions {
margin-left: auto;
}
.status-color-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.status-color-row .form-control-color {
width: 40px;
padding: 0.25rem;
flex-shrink: 0;
}
</style>

View File

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

View File

@@ -187,79 +187,7 @@ const createProvider = async () => {
onMounted(loadProviders); onMounted(loadProviders);
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/ManageAuthProvidersModal.css"></style>
.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

@@ -2,8 +2,27 @@
<div class="note-editor"> <div class="note-editor">
<div class="editor-toolbar mb-3"> <div class="editor-toolbar mb-3">
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button> <button class="btn btn-sm btn-primary" @click="saveNote">Save</button>
<button v-if="canDelete" class="btn btn-sm btn-danger ms-2" @click="confirmDelete">Delete</button>
<button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button> <button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
<button
v-if="fileExplorerEnabled"
class="btn btn-sm ms-2"
:class="showFileExplorer ? 'btn-secondary' : 'btn-outline-secondary'"
:title="showFileExplorer ? 'Hide file explorer' : 'Browse & insert files'"
@click="showFileExplorer = !showFileExplorer"
>
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
Files
</button>
<button
v-if="spaceId"
class="btn btn-sm"
:class="showTaskPicker ? 'btn-secondary' : 'btn-outline-secondary'"
:title="showTaskPicker ? 'Hide task picker' : 'Browse & insert task mentions'"
@click="toggleTaskPicker"
>
<i class="mdi mdi-checkbox-marked-circle-outline me-1" aria-hidden="true"></i>
Tasks
</button>
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span> <span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
</div> </div>
@@ -16,13 +35,50 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div :class="editorColumnClass">
<textarea v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea> <textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
<div v-if="showTaskMention" class="task-mention-panel">
<div class="small text-muted mb-1">Link task for "{{ taskMentionQuery }}"</div>
<button v-for="task in taskMentionResults" :key="task.id" class="task-mention-option" @click="selectMentionTask(task)">
<span>{{ task.title }}</span>
<small>Depth {{ task.depth + 1 }}</small>
</button>
</div>
</div> </div>
<div class="col-12 col-md-6 mt-3 mt-md-0"> <div :class="previewColumnClass">
<div class="preview-pane border rounded p-3"> <div class="preview-pane border rounded p-3" @click="onPreviewClick">
<div v-html="renderedMarkdown"></div> <div class="markdown-body" v-html="renderedMarkdown"></div>
</div>
</div>
<div v-if="showFileExplorer" :class="fileExplorerColumnClass">
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
</div>
<div v-if="showTaskPicker" :class="taskPickerColumnClass">
<div class="task-picker border rounded">
<div class="task-picker-header px-2 py-1 border-bottom d-flex align-items-center gap-2">
<i class="mdi mdi-checkbox-marked-circle-outline text-muted" aria-hidden="true"></i>
<span class="small fw-semibold">Space Tasks</span>
<button class="btn btn-link btn-sm p-0 text-muted ms-auto" title="Refresh" @click="refreshTaskPicker">
<i class="mdi mdi-refresh" aria-hidden="true"></i>
</button>
</div>
<div class="task-picker-search p-2 border-bottom">
<input v-model="taskPickerQuery" type="text" class="form-control form-control-sm" placeholder="Search tasks by title..." />
</div>
<div v-if="taskPickerLoading" class="task-picker-empty text-muted small">
<i class="mdi mdi-loading mdi-spin me-1" aria-hidden="true"></i>
Loading tasks...
</div>
<div v-else-if="!taskPickerItems.length" class="task-picker-empty text-muted small">No tasks found.</div>
<div v-else class="task-picker-list">
<button v-for="task in taskPickerItems" :key="task.id" class="task-picker-item" @click="insertTaskMention(task)">
<span class="task-picker-title">{{ task.title }}</span>
<small>{{ task.picker_status_name }}</small>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -68,15 +124,34 @@
</select> </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" /> <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>
<DangerZonePanel v-if="canDelete && editingNote.id" class="mt-4" title-id="danger-zone-title" title="Danger Zone" description="Deleting this note is permanent and cannot be undone.">
<button class="btn btn-danger" type="button" @click="requestDelete">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Note
</button>
</DangerZonePanel>
</div> </div>
</div> </div>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
title="Delete Note"
message="Are you sure you want to delete this note? This action cannot be undone."
@close="showDeleteConfirmModal = false"
@confirm="confirmDelete"
/>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onBeforeUnmount, onMounted } from "vue"; import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore"; import { useSettingsStore } from "../stores/settingsStore";
import { useSpaceStore } from "../stores/spaceStore";
import { renderMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({ const props = defineProps({
note: { note: {
@@ -91,25 +166,133 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
spaceId: {
type: String,
default: "",
},
}); });
const emit = defineEmits(["save", "delete", "cancel"]); const emit = defineEmits(["save", "delete", "cancel", "open-linked-task"]);
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const spaceStore = useSpaceStore();
const publicSharingEnabled = ref(true); const publicSharingEnabled = ref(true);
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
const editingNote = ref({ ...props.note }); const editingNote = ref({ ...props.note });
const contentTextareaRef = ref(null);
const showFileExplorer = ref(false);
const fileExplorerPrefix = ref("");
const tagsInput = ref(props.note.tags?.join(", ") || ""); const tagsInput = ref(props.note.tags?.join(", ") || "");
const passwordAction = ref("keep"); const passwordAction = ref("keep");
const notePassword = ref(""); const notePassword = ref("");
const saveTimeout = ref(null); const saveTimeout = ref(null);
const saveState = ref("saved"); const saveState = ref("saved");
const saveStateTimeout = ref(null); const saveStateTimeout = ref(null);
const taskMentionQuery = ref("");
const taskMentionResults = ref([]);
const showTaskMention = ref(false);
const linkedTasks = ref([]);
const showTaskPicker = ref(false);
const taskPickerQuery = ref("");
const taskPickerLoading = ref(false);
const showDeleteConfirmModal = ref(false);
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
const editorColumnClass = computed(() => {
if (hasTwoAuxPanels.value) {
return "col-12 col-xl-4";
}
return hasAuxPanels.value ? "col-12 col-md-5" : "col-12 col-md-6";
});
const previewColumnClass = computed(() => {
if (hasTwoAuxPanels.value) {
return "col-12 col-xl-4 mt-3 mt-xl-0";
}
return hasAuxPanels.value ? "col-12 col-md-4 mt-3 mt-md-0" : "col-12 col-md-6 mt-3 mt-md-0";
});
const fileExplorerColumnClass = computed(() => {
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
});
const taskPickerColumnClass = computed(() => {
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
});
const taskStatusNameById = computed(() => {
const map = new Map();
for (const status of spaceStore.taskStatuses || []) {
if (status?.id) {
map.set(status.id, status.name || "");
}
}
return map;
});
const taskPickerItems = computed(() => {
const query = taskPickerQuery.value.trim().toLowerCase();
const allTasks = [...(spaceStore.tasks || [])]
.map((task) => ({
...task,
picker_status_name: task.status_name || task.status?.name || taskStatusNameById.value.get(task.status_id) || "Unknown",
}))
.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
if (!query) {
return allTasks;
}
return allTasks.filter((task) => (task.title || "").toLowerCase().includes(query));
});
const renderedMarkdown = computed(() => { const renderedMarkdown = computed(() => {
const html = marked.parse(editingNote.value.content || ""); const html = renderMarkdown(enrichTaskMentions(editingNote.value.content || ""));
return DOMPurify.sanitize(html); return DOMPurify.sanitize(html);
}); });
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
const taskByTitle = computed(() => {
const map = new Map();
for (const task of linkedTasks.value || []) {
const key = normalizeTaskTitle(task.title);
if (!key || map.has(key)) {
continue;
}
map.set(key, task);
}
return map;
});
const enrichTaskMentions = (content) => {
if (!content) {
return "";
}
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
const title = (rawTitle || "").trim();
if (!title) {
return full;
}
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
if (!linkedTask?.id) {
return full;
}
const statusName = (linkedTask.status_name || "Unknown").trim();
const safeTitle = title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const safeStatusName = statusName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const statusColor = (linkedTask.status_color || "").trim();
const safeStatusColor = statusColor.replace(/"/g, "").replace(/</g, "").replace(/>/g, "");
const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : "";
return `<a href="#task:${linkedTask.id}" class="task-inline-link"${styleAttr}><i class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></i><span class="task-inline-title">${safeTitle}</span><span class="task-inline-status">${safeStatusName}</span></a>`;
});
};
const saveStatusLabel = computed(() => { const saveStatusLabel = computed(() => {
switch (saveState.value) { switch (saveState.value) {
case "dirty": case "dirty":
@@ -124,12 +307,21 @@ const saveStatusLabel = computed(() => {
watch( watch(
() => props.note, () => props.note,
(newNote) => { async (newNote) => {
editingNote.value = { ...newNote }; editingNote.value = { ...newNote };
tagsInput.value = newNote.tags?.join(", ") || ""; tagsInput.value = newNote.tags?.join(", ") || "";
passwordAction.value = "keep"; passwordAction.value = "keep";
notePassword.value = ""; notePassword.value = "";
saveState.value = "saved"; saveState.value = "saved";
if (props.spaceId && newNote?.id) {
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, newNote.id);
} catch {
linkedTasks.value = [];
}
} else {
linkedTasks.value = [];
}
}, },
); );
@@ -180,20 +372,199 @@ const saveNote = () => {
notePassword.value = ""; notePassword.value = "";
} }
markSavedSoon(); markSavedSoon();
// Auto-link any @task(Title) mentions present in the saved content
syncTaskMentionLinks(note.content || "");
}; };
const autoSave = () => { const autoSave = () => {
saveState.value = "dirty"; saveState.value = "dirty";
clearTimeout(saveTimeout.value); clearTimeout(saveTimeout.value);
saveTimeout.value = setTimeout(saveNote, 3000); saveTimeout.value = setTimeout(saveNote, 3000);
detectTaskMention();
};
const requestDelete = () => {
if (!props.canDelete) {
return;
}
showDeleteConfirmModal.value = true;
}; };
const confirmDelete = () => { const confirmDelete = () => {
if (!props.canDelete) { if (!props.canDelete) {
return; return;
} }
if (confirm("Are you sure you want to delete this note?")) { if (!editingNote.value?.id) {
emit("delete", editingNote.value.id); return;
}
showDeleteConfirmModal.value = false;
emit("delete", editingNote.value.id);
};
/** Insert markdown snippet at the textarea cursor position. */
const insertAtCursor = (snippet) => {
const textarea = contentTextareaRef.value;
if (!textarea) {
editingNote.value.content = (editingNote.value.content || "") + snippet;
autoSave();
return;
}
const start = textarea.selectionStart ?? editingNote.value.content?.length ?? 0;
const end = textarea.selectionEnd ?? start;
const before = (editingNote.value.content || "").substring(0, start);
const after = (editingNote.value.content || "").substring(end);
editingNote.value.content = before + snippet + after;
autoSave();
nextTick(() => {
const newPos = start + snippet.length;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
});
};
const detectTaskMention = async () => {
const content = editingNote.value.content || "";
const match = content.match(/@task\s+([^\n]{1,40})$/i);
if (!match || !props.spaceId) {
showTaskMention.value = false;
taskMentionResults.value = [];
taskMentionQuery.value = "";
return;
}
const query = match[1].trim();
taskMentionQuery.value = query;
if (!query) {
showTaskMention.value = false;
taskMentionResults.value = [];
return;
}
try {
taskMentionResults.value = await spaceStore.searchTasks(props.spaceId, query);
showTaskMention.value = taskMentionResults.value.length > 0;
} catch {
taskMentionResults.value = [];
showTaskMention.value = false;
}
};
const replaceTaskMentionText = (title) => {
editingNote.value.content = (editingNote.value.content || "").replace(/@task\s+([^\n]{1,40})$/i, `@task(${title})`);
};
const selectMentionTask = async (task) => {
replaceTaskMentionText(task.title);
showTaskMention.value = false;
taskMentionResults.value = [];
if (!props.spaceId || !editingNote.value.id) {
return;
}
try {
await spaceStore.linkTaskToNote(props.spaceId, task.id, editingNote.value.id);
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
alert("Unable to link task to this note.");
}
autoSave();
};
const insertTaskMention = (task) => {
if (!task?.title) {
return;
}
insertAtCursor(`@task(${task.title})`);
};
const refreshTaskPicker = async () => {
if (!props.spaceId) {
return;
}
taskPickerLoading.value = true;
try {
await Promise.all([spaceStore.fetchTasks(props.spaceId), spaceStore.fetchTaskStatuses(props.spaceId)]);
} finally {
taskPickerLoading.value = false;
}
};
const toggleTaskPicker = async () => {
showTaskPicker.value = !showTaskPicker.value;
if (showTaskPicker.value && !spaceStore.tasks.length) {
await refreshTaskPicker();
}
};
/**
* Parse all @task(Title) mentions in content and ensure each is linked.
* Called after every real save so new mentions are linked automatically.
*/
const syncTaskMentionLinks = async (content) => {
if (!props.spaceId || !editingNote.value.id) {
return;
}
const mentionTitles = new Set();
const rx = /@task\(([^)]+)\)/gi;
let m;
while ((m = rx.exec(content)) !== null) {
const title = (m[1] || "").trim();
if (title) {
mentionTitles.add(title.toLowerCase());
}
}
if (!mentionTitles.size) {
return;
}
let current;
try {
current = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
return;
}
const linkedTitles = new Set((current || []).map((t) => (t.title || "").toLowerCase()));
const toLink = [...mentionTitles].filter((title) => !linkedTitles.has(title));
if (!toLink.length) {
linkedTasks.value = current;
return;
}
await Promise.all(
toLink.map(async (title) => {
try {
const results = await spaceStore.searchTasks(props.spaceId, title);
const exact = results.find((t) => (t.title || "").toLowerCase() === title);
if (exact) {
await spaceStore.linkTaskToNote(props.spaceId, exact.id, editingNote.value.id);
}
} catch {
// best-effort — skip silently
}
}),
);
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
// ignore
}
};
const onPreviewClick = (event) => {
const anchor = event.target?.closest?.("a");
if (!anchor) {
return;
}
const href = anchor.getAttribute("href") || "";
if (!href.startsWith("#task:")) {
return;
}
event.preventDefault();
const taskId = href.slice("#task:".length);
if (!taskId) {
return;
}
const matchedTask = (linkedTasks.value || []).find((task) => task.id === taskId);
if (matchedTask) {
emit("open-linked-task", matchedTask);
} }
}; };
@@ -205,75 +576,17 @@ onBeforeUnmount(() => {
onMounted(async () => { onMounted(async () => {
await settingsStore.loadFeatureFlags(); await settingsStore.loadFeatureFlags();
publicSharingEnabled.value = settingsStore.publicSharingEnabled; publicSharingEnabled.value = settingsStore.publicSharingEnabled;
if (props.spaceId && editingNote.value?.id) {
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
linkedTasks.value = [];
}
}
if (props.spaceId && !spaceStore.tasks.length) {
await refreshTaskPicker();
}
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/NoteEditor.css"></style>
.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

@@ -1,221 +0,0 @@
<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

@@ -24,14 +24,14 @@
</div> </div>
</header> </header>
<div class="markdown-body" v-html="renderedMarkdown"></div> <div class="markdown-body" v-html="renderedMarkdown" @click="onMarkdownClick"></div>
</article> </article>
</template> </template>
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { renderMarkdown } from "../utils/markdown.js";
const props = defineProps({ const props = defineProps({
note: { note: {
@@ -42,13 +42,65 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
spaceId: {
type: String,
default: "",
},
linkedTasks: {
type: Array,
default: () => [],
},
}); });
const emit = defineEmits(["open-linked-task"]);
const renderedMarkdown = computed(() => { const renderedMarkdown = computed(() => {
const html = marked.parse(props.note.content || ""); const html = renderMarkdown(enrichTaskMentions(props.note.content || ""));
return DOMPurify.sanitize(html); return DOMPurify.sanitize(html);
}); });
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
const taskByTitle = computed(() => {
const map = new Map();
for (const task of props.linkedTasks || []) {
const key = normalizeTaskTitle(task.title);
if (!key || map.has(key)) {
continue;
}
map.set(key, task);
}
return map;
});
const enrichTaskMentions = (content) => {
if (!content) {
return "";
}
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
const title = (rawTitle || "").trim();
if (!title) {
return full;
}
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
if (!linkedTask?.id) {
return full;
}
const statusName = (linkedTask.status_name || "Unknown").trim();
const safeTitle = title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const safeStatusName = statusName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const statusColor = (linkedTask.status_color || "").trim();
const safeStatusColor = statusColor.replace(/"/g, "").replace(/</g, "").replace(/>/g, "");
const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : "";
return `<a href="#task:${linkedTask.id}" class="task-inline-link"${styleAttr}><i class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></i><span class="task-inline-title">${safeTitle}</span><span class="task-inline-status">${safeStatusName}</span></a>`;
});
};
const categoryLabel = computed(() => { const categoryLabel = computed(() => {
const categoryId = props.note.category_id; const categoryId = props.note.category_id;
if (!categoryId) { if (!categoryId) {
@@ -75,101 +127,29 @@ const categoryLabel = computed(() => {
}); });
const formatDateTime = (dateString) => new Date(dateString).toLocaleString(); const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
const onMarkdownClick = (event) => {
const anchor = event.target?.closest?.("a");
if (!anchor) {
return;
}
const href = anchor.getAttribute("href") || "";
if (!href.startsWith("#task:")) {
return;
}
event.preventDefault();
const taskId = href.slice("#task:".length);
if (!taskId) {
return;
}
const matchedTask = (props.linkedTasks || []).find((task) => task.id === taskId);
if (matchedTask) {
emit("open-linked-task", matchedTask);
}
};
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/NoteViewer.css"></style>
.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,85 @@
<template>
<section class="search-results-page">
<header class="search-results-header">
<h2>Search Results</h2>
<p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p>
<p v-else class="search-meta">Type in the top bar and press Enter to search notes and task lists.</p>
</header>
<div v-if="!query" class="empty-state">
<i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i>
<h3>Start your search</h3>
<p>Use a title, content keyword, or tag to find matching notes and task lists in the selected space.</p>
</div>
<div v-else-if="totalResults === 0" class="empty-state">
<i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i>
<h3>No matching results</h3>
<p>Try different keywords or a shorter phrase.</p>
</div>
<div v-else>
<WorkspaceList :items="paginatedItems" :view-mode="viewMode" @select-note="emit('select-note', $event)" @select-task-list="emit('select-task-list', $event)" />
<nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages">
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button>
<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>
<button class="btn btn-outline-secondary" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)">Next</button>
</nav>
</div>
</section>
</template>
<script setup>
import { computed } from "vue";
import WorkspaceList from "./WorkspaceList.vue";
const props = defineProps({
query: {
type: String,
default: "",
},
items: {
type: Array,
default: () => [],
},
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 12,
},
viewMode: {
type: String,
default: "grid",
},
});
const emit = defineEmits(["select-note", "select-task-list", "page-change"]);
const totalResults = computed(() => props.items.length);
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize)));
const normalizedPage = computed(() => {
if (!Number.isFinite(props.currentPage) || props.currentPage < 1) {
return 1;
}
return Math.min(props.currentPage, totalPages.value);
});
const paginatedItems = computed(() => {
const start = (normalizedPage.value - 1) * props.pageSize;
return props.items.slice(start, start + props.pageSize);
});
const goToPage = (page) => {
if (page < 1 || page > totalPages.value) {
return;
}
emit("page-change", page);
};
</script>
<style scoped src="../assets/styles/scoped/components/SearchResultsPage.css"></style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<teleport to="body"> <teleport v-if="!showDeleteConfirmModal" to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <div class="modal fade show d-block" 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-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -76,13 +76,17 @@
<template v-if="canDeleteSpace"> <template v-if="canDeleteSpace">
<hr /> <hr />
<div class="border border-danger rounded p-3 mt-3"> <DangerZonePanel
<h6 class="text-danger mb-1">Danger Zone</h6> class="mt-4"
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p> title-id="danger-zone-title"
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace"> title="Danger Zone"
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
{{ deleting ? "Deleting..." : "Delete Space" }} {{ deleting ? "Deleting..." : "Delete Space" }}
</button> </button>
</div> </DangerZonePanel>
</template> </template>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div> <div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
@@ -93,12 +97,23 @@
</div> </div>
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop fade show"></div>
</teleport> </teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
space: { space: {
@@ -130,6 +145,20 @@ const success = ref("");
const memberForm = ref({ user_id: "" }); const memberForm = ref({ user_id: "" });
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view")); const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage")); const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "member") {
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
return `Remove member "${memberName}" from this space?`;
}
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
});
watch( watch(
() => props.space, () => props.space,
@@ -224,13 +253,24 @@ const addMember = async () => {
} }
}; };
const removeMember = async (member) => { const removeMember = (member) => {
if (!canManageMembers.value) { if (!canManageMembers.value) {
return; return;
} }
const memberName = member?.username || member?.user_id; if (!member?.user_id) {
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) { return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return; return;
} }
@@ -251,10 +291,15 @@ if (canViewMembers.value) {
Promise.all([loadMembers(), loadUserOptions()]); Promise.all([loadMembers(), loadUserOptions()]);
} }
const deleteSpace = async () => { const requestDeleteSpace = () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) { deleteConfirmIntent.value = {
return; type: "space",
} payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true; deleting.value = true;
clearMessages(); clearMessages();
try { try {
@@ -262,8 +307,49 @@ const deleteSpace = async () => {
emit("deleted", props.space); emit("deleted", props.space);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete space."; error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
}; };
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "member") {
await removeMemberConfirmed(payload);
} else if (type === "space") {
await deleteSpaceConfirmed();
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
</script> </script>

View File

@@ -0,0 +1,298 @@
<template>
<section class="task-board">
<div class="task-board-header">
<div class="task-title-wrap">
<h4 class="mb-0">Tasks</h4>
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
</div>
<button v-if="selectedTaskList" class="btn btn-sm btn-outline-secondary" @click="emit('edit-task-list')"><i class="mdi mdi-cog-outline me-1" aria-hidden="true"></i>Edit Task List</button>
</div>
<div class="task-filters">
<select v-model="filterStatus" class="form-select" @change="emitFilters">
<option value="">All statuses</option>
<option v-for="status in statuses" :key="status.id" :value="status.id">
{{ status.name }}
</option>
</select>
<select v-model="filterParent" class="form-select" @change="emitFilters">
<option value="">Any parent</option>
<option value="root">Top-level only</option>
<option v-for="task in parentTaskOptions" :key="task.id" :value="task.id">
{{ task.title }}
</option>
</select>
</div>
<div class="task-status-groups">
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
<section v-for="section in statusSections" :key="section.status.id" class="status-group">
<header class="status-group-header" :style="statusHeaderStyle(section.status)">
<div class="status-group-title-wrap">
<span class="status-group-dot" :style="{ backgroundColor: section.status.color || '#7c8596' }"></span>
<span class="status-group-title">{{ section.status.name }}</span>
</div>
<span class="status-group-count">{{ section.parentTasks.length }}</span>
</header>
<div v-if="!section.parentTasks.length" class="status-empty">No tasks in this status.</div>
<div v-for="parentTask in section.parentTasks" :key="parentTask.id" class="task-tree-row level-0">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', parentTask)"
@keydown.enter="emit('select-task', parentTask)"
@keydown.space.prevent="emit('select-task', parentTask)"
>
<span class="tree-toggle" @click.stop="toggleExpanded(parentTask)">
<i v-if="hasChildren(parentTask)" :class="isExpanded(parentTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
</span>
<span class="task-main">
<strong>{{ parentTask.title }}</strong>
<small class="text-muted">{{ parentTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(parentTask.status_id)}`" @click="toggleStatusMenu(parentTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(parentTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(parentTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: parentTask.status_id === status.id }"
@click="onTaskStatusChange(parentTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="parentTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div v-if="isExpanded(parentTask.id)">
<div v-for="childTask in childrenFor(parentTask.id)" :key="childTask.id" class="task-tree-row level-1">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', childTask)"
@keydown.enter="emit('select-task', childTask)"
@keydown.space.prevent="emit('select-task', childTask)"
>
<span class="tree-toggle" @click.stop="toggleExpanded(childTask)">
<i v-if="hasChildren(childTask)" :class="isExpanded(childTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
</span>
<span class="task-main">
<strong>{{ childTask.title }}</strong>
<small class="text-muted">{{ childTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(childTask.status_id)}`" @click="toggleStatusMenu(childTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(childTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(childTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: childTask.status_id === status.id }"
@click="onTaskStatusChange(childTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="childTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div v-if="isExpanded(childTask.id)">
<div v-for="grandchildTask in childrenFor(childTask.id)" :key="grandchildTask.id" class="task-tree-row level-2">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', grandchildTask)"
@keydown.enter="emit('select-task', grandchildTask)"
@keydown.space.prevent="emit('select-task', grandchildTask)"
>
<span class="tree-toggle"></span>
<span class="task-main">
<strong>{{ grandchildTask.title }}</strong>
<small class="text-muted">{{ grandchildTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(grandchildTask.status_id)}`" @click="toggleStatusMenu(grandchildTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(grandchildTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(grandchildTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: grandchildTask.status_id === status.id }"
@click="onTaskStatusChange(grandchildTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="grandchildTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({
tasks: {
type: Array,
default: () => [],
},
statuses: {
type: Array,
default: () => [],
},
selectedTaskList: {
type: Object,
default: null,
},
});
const emit = defineEmits(["create-task", "select-task", "filter-change", "update-task-status", "edit-task-list"]);
const filterStatus = ref("");
const filterParent = ref("");
const expandedTaskIds = ref({});
const openStatusMenuTaskId = ref("");
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
const tasksById = computed(() => {
const map = new Map();
for (const task of props.tasks) {
map.set(task.id, task);
}
return map;
});
const tasksByParentId = computed(() => {
const map = new Map();
for (const task of props.tasks) {
if (!task.parent_task_id) {
continue;
}
const existing = map.get(task.parent_task_id) || [];
existing.push(task);
map.set(task.parent_task_id, existing);
}
for (const [key, children] of map) {
map.set(
key,
[...children].sort((a, b) => (a.title || "").localeCompare(b.title || "")),
);
}
return map;
});
const parentTasks = computed(() => props.tasks.filter((task) => !task.parent_task_id || !tasksById.value.has(task.parent_task_id)));
const statusSections = computed(() =>
props.statuses.map((status) => ({
status,
parentTasks: parentTasks.value.filter((task) => task.status_id === status.id),
})),
);
const emitFilters = () => {
emit("filter-change", {
statusId: filterStatus.value || null,
parentTaskId: filterParent.value || null,
});
};
const statusHeaderStyle = (status) => {
const color = status.color || "#7c8596";
return {
borderColor: color,
};
};
const statusColor = (statusId) => props.statuses.find((status) => status.id === statusId)?.color || "#7c8596";
const statusName = (statusId) => props.statuses.find((status) => status.id === statusId)?.name || "Unknown";
const statusDotStyle = (statusId) => ({
backgroundColor: statusColor(statusId),
});
const isStatusMenuOpen = (taskId) => openStatusMenuTaskId.value === taskId;
const toggleStatusMenu = (taskId) => {
openStatusMenuTaskId.value = openStatusMenuTaskId.value === taskId ? "" : taskId;
};
const closeStatusMenu = () => {
openStatusMenuTaskId.value = "";
};
const onDocumentClick = () => {
closeStatusMenu();
};
const childrenFor = (parentId) => tasksByParentId.value.get(parentId) || [];
const hasChildren = (task) => childrenFor(task.id).length > 0;
const isExpanded = (taskId) => !!expandedTaskIds.value[taskId];
const toggleExpanded = (task) => {
if (!hasChildren(task)) {
return;
}
expandedTaskIds.value = {
...expandedTaskIds.value,
[task.id]: !expandedTaskIds.value[task.id],
};
};
const onTaskStatusChange = (task, statusId) => {
if (!task?.id || !statusId || task.status_id === statusId) {
closeStatusMenu();
return;
}
emit("update-task-status", {
taskId: task.id,
currentStatusId: task.status_id,
targetStatusId: statusId,
});
closeStatusMenu();
};
onMounted(() => {
document.addEventListener("click", onDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick);
});
</script>
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>

View File

@@ -0,0 +1,137 @@
<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">{{ localTask.id ? "Task Detail" : "Create Task" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12 col-lg-7">
<label class="form-label">Title</label>
<input v-model="localTask.title" class="form-control" type="text" maxlength="255" />
<label class="form-label mt-3">Description</label>
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
<label class="form-label mt-3">Parent Task</label>
<select v-model="localTask.parent_task_id" class="form-select">
<option value="">No parent (top level)</option>
<option v-for="option in parentTaskOptions" :key="option.id" :value="option.id">{{ option.title }}</option>
</select>
</div>
<div class="col-12 col-lg-5">
<label class="form-label">Status</label>
<select v-model="localTask.status_id" class="form-select">
<option v-for="status in statuses" :key="status.id" :value="status.id">{{ status.name }}</option>
</select>
<div class="status-progress mt-3">
<div v-for="status in statuses" :key="status.id" class="progress-step" :class="stepClass(status)">
<span class="dot" :style="{ borderColor: status.color || '#7c8596', backgroundColor: isReached(status) ? status.color || '#7c8596' : 'transparent' }"></span>
<span>{{ status.name }}</span>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-outline-secondary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'backward' })">Revert</button>
<button class="btn btn-outline-primary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'forward' })">Advance</button>
</div>
<div class="mt-4">
<h6>Subtasks</h6>
<div v-if="!subtasks.length" class="text-muted small">No subtasks yet.</div>
<button v-for="subtask in subtasks" :key="subtask.id" class="subtask-row" @click="emit('open-task', subtask)">
<span>{{ subtask.title }}</span>
<small>L{{ subtask.depth + 1 }}</small>
</button>
<button v-if="canAddSubtask" class="btn btn-sm btn-outline-primary mt-2" @click="emit('create-subtask', localTask)">Add Subtask</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Close</button>
<button v-if="localTask.id" type="button" class="btn btn-danger" @click="emit('delete-task', localTask)">Delete</button>
<button type="button" class="btn btn-primary" @click="saveTask">Save</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
task: {
type: Object,
default: () => ({}),
},
statuses: {
type: Array,
default: () => [],
},
parentTaskOptions: {
type: Array,
default: () => [],
},
subtasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["close", "save-task", "delete-task", "transition", "create-subtask", "open-task"]);
const localTask = ref({});
watch(
() => props.task,
(value) => {
localTask.value = {
title: "",
description: "",
task_list_id: "",
status_id: props.statuses[0]?.id || "",
parent_task_id: "",
note_links: [],
...value,
task_list_id: value?.task_list_id || "",
parent_task_id: value?.parent_task_id || "",
note_links: value?.note_links || [],
};
},
{ immediate: true },
);
const canAddSubtask = computed(() => !!localTask.value.id && (localTask.value.depth ?? 0) < 2);
const isReached = (status) => {
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
return status.order <= current;
};
const stepClass = (status) => {
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
return {
current: status.order === current,
done: status.order < current,
};
};
const saveTask = () => {
emit("save-task", {
...localTask.value,
task_list_id: localTask.value.task_list_id || null,
parent_task_id: localTask.value.parent_task_id || null,
});
};
</script>
<style scoped src="../assets/styles/scoped/components/TaskDetailModal.css"></style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="workspace-list" :class="{ 'workspace-list--list': viewMode === 'list' }">
<div v-if="items.length === 0" class="empty-workspace-state" role="status" aria-live="polite">
<i class="mdi mdi-view-grid-outline empty-workspace-icon" aria-hidden="true"></i>
<h3 class="empty-workspace-title">Nothing Here Yet</h3>
<p class="empty-workspace-message">This view has no notes or task lists yet.</p>
</div>
<div v-for="item in items" :key="`${item.kind}-${item.id}`" class="content-card" :class="contentCardClass(item)" @click="openItem(item)">
<h5 class="content-title">
<template v-if="item.kind === 'note'">
<i v-if="item.is_pinned" class="mdi mdi-pin pin-icon" aria-hidden="true"></i>
<i v-else-if="item.is_favorite || item.is_featured" class="mdi mdi-star featured-icon" aria-hidden="true"></i>
{{ item.title }}
</template>
<template v-else>
<i class="mdi mdi-format-list-checkbox list-icon" aria-hidden="true"></i>
{{ item.name }}
</template>
</h5>
<p class="content-preview">{{ getDescription(item) }}</p>
<small class="text-muted">Updated: {{ formatDate(item.updated_at) }}</small>
</div>
<div v-if="canLoadMore" class="list-footer">
<button class="btn btn-outline-secondary" :disabled="isLoadingMore" @click.stop="emit('loadMore')">
{{ isLoadingMore ? "Loading..." : "Load more" }}
</button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
default: () => [],
},
canLoadMore: {
type: Boolean,
default: false,
},
isLoadingMore: {
type: Boolean,
default: false,
},
viewMode: {
type: String,
default: "grid",
},
});
const emit = defineEmits(["selectNote", "selectTaskList", "loadMore"]);
const openItem = (item) => {
if (item.kind === "task-list") {
emit("selectTaskList", item);
return;
}
emit("selectNote", item);
};
const formatDate = (dateString) => new Date(dateString).toLocaleDateString();
const getDescription = (item) => {
const description = (item?.description || "").trim();
if (description) {
return description;
}
return item.kind === "task-list" ? "Open this task list to manage tasks." : "No description";
};
const contentCardClass = (item) => ({
"is-pinned": item.kind === "note" && item.is_pinned,
"is-featured": item.kind === "note" && (item.is_favorite || item.is_featured),
"is-task-list": item.kind === "task-list",
});
</script>
<style scoped src="../assets/styles/scoped/components/WorkspaceList.css"></style>

View File

@@ -0,0 +1,145 @@
<template>
<CreateSpaceModal v-if="showCreateSpaceModal" @close="emit('close-create-space')" @create="emit('create-space', $event)" />
<CreateCategoryModal
v-if="showCreateCategoryModal"
:category="editingCategory"
:parent-options="categoryParentOptions"
:parent-id="categoryModalParentId"
@close="emit('close-create-category')"
@submit="emit('submit-category', $event)"
/>
<CreateNoteModal
v-if="showCreateNoteModal"
:category-options="categoryOptions"
:default-category-id="selectedCategoryId"
@close="emit('close-create-note')"
@create="emit('create-note', $event)"
/>
<CreateTaskListModal
v-if="showCreateTaskListModal"
:category-options="categoryOptions"
:default-category-id="selectedCategoryId"
@close="emit('close-create-task-list')"
@create="emit('create-task-list', $event)"
/>
<SpaceSettingsModal
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
:space="currentSpace"
@close="emit('close-space-settings')"
@saved="emit('saved-space', $event)"
@deleted="emit('deleted-space', $event)"
/>
<TaskDetailModal
v-if="showTaskModal"
:task="taskModalDraft || {}"
:statuses="taskStatuses"
:parent-task-options="taskParentOptions"
:subtasks="taskDetailSubtasks"
@close="emit('close-task-modal')"
@save-task="emit('save-task', $event)"
@delete-task="emit('delete-task', $event)"
@transition="emit('transition-task', $event)"
@create-subtask="emit('create-subtask', $event)"
@open-task="emit('open-task', $event)"
/>
</template>
<script setup>
import CreateSpaceModal from "../CreateSpaceModal.vue";
import CreateCategoryModal from "../CreateCategoryModal.vue";
import CreateNoteModal from "../CreateNoteModal.vue";
import CreateTaskListModal from "../CreateTaskListModal.vue";
import SpaceSettingsModal from "../SpaceSettingsModal.vue";
import TaskDetailModal from "../TaskDetailModal.vue";
defineProps({
showCreateSpaceModal: {
type: Boolean,
default: false,
},
showCreateCategoryModal: {
type: Boolean,
default: false,
},
editingCategory: {
type: Object,
default: null,
},
categoryParentOptions: {
type: Array,
default: () => [],
},
categoryModalParentId: {
type: [String, Number, null],
default: null,
},
showCreateNoteModal: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
selectedCategoryId: {
type: [String, Number, null],
default: null,
},
showCreateTaskListModal: {
type: Boolean,
default: false,
},
showSpaceSettingsModal: {
type: Boolean,
default: false,
},
currentSpace: {
type: Object,
default: null,
},
canManageSpaceSettings: {
type: Boolean,
default: false,
},
showTaskModal: {
type: Boolean,
default: false,
},
taskModalDraft: {
type: Object,
default: null,
},
taskStatuses: {
type: Array,
default: () => [],
},
taskParentOptions: {
type: Array,
default: () => [],
},
taskDetailSubtasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits([
"close-create-space",
"create-space",
"close-create-category",
"submit-category",
"close-create-note",
"create-note",
"close-create-task-list",
"create-task-list",
"close-space-settings",
"saved-space",
"deleted-space",
"close-task-modal",
"save-task",
"delete-task",
"transition-task",
"create-subtask",
"open-task",
]);
</script>

View File

@@ -0,0 +1,160 @@
<template>
<div class="content p-4">
<TaskBoard
v-if="activeView === 'tasks'"
:tasks="tasks"
:statuses="taskStatuses"
:selected-task-list="selectedTaskList"
@select-task="emit('select-task', $event)"
@filter-change="emit('filter-change', $event)"
@update-task-status="emit('update-task-status', $event)"
@edit-task-list="emit('edit-task-list')"
/>
<SearchResultsPage
v-else-if="isSearchRoute"
:items="searchItems"
:query="searchQuery"
:current-page="searchPage"
:page-size="searchPageSize"
:view-mode="noteViewMode"
@select-note="emit('select-note', $event)"
@select-task-list="emit('select-task-list', $event)"
@page-change="emit('page-change', $event)"
/>
<NoteEditor
v-else-if="selectedNote && isEditingNote"
:note="selectedNote"
:category-options="categoryOptions"
:can-delete="canDeleteNotes"
:space-id="currentSpaceId"
@save="emit('save-note', $event)"
@delete="emit('delete-note', $event)"
@cancel="emit('cancel-edit-note')"
@open-linked-task="emit('open-linked-task', $event)"
/>
<NoteViewer
v-else-if="selectedNote"
:note="selectedNote"
:category-options="categoryOptions"
:space-id="currentSpaceId"
:linked-tasks="linkedTasksForSelectedNote"
@open-linked-task="emit('open-linked-task', $event)"
/>
<WorkspaceList
v-else
:items="displayedItems"
:can-load-more="canLoadMoreMainNotes"
:is-loading-more="isLoadingMoreMainNotes"
:view-mode="noteViewMode"
@select-note="emit('select-note', $event)"
@select-task-list="emit('select-task-list', $event)"
@load-more="emit('load-more')"
/>
</div>
</template>
<script setup>
import TaskBoard from "../TaskBoard.vue";
import SearchResultsPage from "../SearchResultsPage.vue";
import NoteEditor from "../NoteEditor.vue";
import NoteViewer from "../NoteViewer.vue";
import WorkspaceList from "../WorkspaceList.vue";
defineProps({
activeView: {
type: String,
required: true,
},
tasks: {
type: Array,
default: () => [],
},
taskStatuses: {
type: Array,
default: () => [],
},
selectedTaskList: {
type: Object,
default: null,
},
canDeleteTasks: {
type: Boolean,
default: false,
},
isSearchRoute: {
type: Boolean,
default: false,
},
searchItems: {
type: Array,
default: () => [],
},
searchQuery: {
type: String,
default: "",
},
searchPage: {
type: Number,
default: 1,
},
searchPageSize: {
type: Number,
default: 12,
},
noteViewMode: {
type: String,
default: "grid",
},
selectedNote: {
type: Object,
default: null,
},
isEditingNote: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
canDeleteNotes: {
type: Boolean,
default: false,
},
currentSpaceId: {
type: String,
default: "",
},
linkedTasksForSelectedNote: {
type: Array,
default: () => [],
},
displayedItems: {
type: Array,
default: () => [],
},
canLoadMoreMainNotes: {
type: Boolean,
default: false,
},
isLoadingMoreMainNotes: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
"select-task",
"filter-change",
"update-task-status",
"edit-task-list",
"select-note",
"select-task-list",
"page-change",
"save-note",
"delete-note",
"cancel-edit-note",
"open-linked-task",
"load-more",
]);
</script>

View File

@@ -4,7 +4,9 @@ import router from "./router";
import App from "./App.vue"; import App from "./App.vue";
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import "@mdi/font/css/materialdesignicons.min.css"; import "@mdi/font/css/materialdesignicons.min.css";
import "highlight.js/styles/github-dark.min.css";
import "./assets/styles/main.css"; import "./assets/styles/main.css";
import "./assets/styles/shared/danger-zone.css";
const app = createApp(App); const app = createApp(App);

Some files were not shown because too many files have changed in this diff Show More