Compare commits
4 Commits
f2735b19e7
...
b23fd25337
| Author | SHA1 | Date | |
|---|---|---|---|
| b23fd25337 | |||
| b8d30af69e | |||
| f82079ff27 | |||
| ec396c7809 |
1
Makefile
1
Makefile
@ -33,6 +33,7 @@ help:
|
|||||||
|
|
||||||
install:
|
install:
|
||||||
@echo "Installing dependencies..."
|
@echo "Installing dependencies..."
|
||||||
|
$(PIP) install -e .
|
||||||
$(PIP) install -r requirements.txt
|
$(PIP) install -r requirements.txt
|
||||||
@echo "Dependencies installed successfully"
|
@echo "Dependencies installed successfully"
|
||||||
|
|
||||||
|
|||||||
196
README.md
Normal file
196
README.md
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
# RBox
|
||||||
|
|
||||||
|
RBox is a self-hosted cloud storage web application designed for secure, scalable file management and sharing. Built with modern web technologies, it provides a comprehensive solution for individuals and organizations seeking full control over their data storage.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
- **File Management**: Upload, download, organize, and manage files with a hierarchical folder structure
|
||||||
|
- **User Authentication**: Secure login with optional two-factor authentication (TOTP)
|
||||||
|
- **File Sharing**: Generate shareable links with customizable permissions and expiration dates
|
||||||
|
- **Search**: Full-text search across file names, metadata, and content
|
||||||
|
- **Thumbnails**: Automatic generation of image and video thumbnails for quick browsing
|
||||||
|
- **Photo Gallery**: Dedicated gallery view with date-based organization and lazy loading
|
||||||
|
- **File Requests**: Create forms for external users to submit files securely
|
||||||
|
- **Activity Logging**: Comprehensive audit trail of all file operations
|
||||||
|
- **Teams**: Organizational structure with role-based access control
|
||||||
|
|
||||||
|
### Storage & Performance
|
||||||
|
- **Multiple Storage Backends**: Support for local filesystem and S3-compatible object storage
|
||||||
|
- **WebDAV Protocol**: Native WebDAV support for seamless integration with desktop clients
|
||||||
|
- **SFTP Support**: Secure file transfer protocol for advanced users
|
||||||
|
- **Caching**: Redis-based caching for improved performance
|
||||||
|
- **Background Processing**: Asynchronous task queues for thumbnail generation and file processing
|
||||||
|
- **Quota Management**: Configurable storage limits per user
|
||||||
|
|
||||||
|
### Security & Compliance
|
||||||
|
- **Encryption**: TLS certificate management with Let's Encrypt integration
|
||||||
|
- **At-Rest Encryption**: Optional encryption of stored files
|
||||||
|
- **End-to-End Encryption**: Client-side encryption mode for maximum security
|
||||||
|
- **IP Whitelisting**: Enterprise-grade access controls
|
||||||
|
- **Audit Logging**: Detailed logs of all permission changes and access attempts
|
||||||
|
|
||||||
|
### Collaboration & Communication
|
||||||
|
- **Real-time Activity Feed**: Live updates on file operations across teams
|
||||||
|
- **Commenting System**: File-level discussions with email notifications
|
||||||
|
- **Email Integration**: SMTP configuration for notifications and invitations
|
||||||
|
- **Webhook Support**: Integration with external services via webhooks
|
||||||
|
|
||||||
|
### Administration
|
||||||
|
- **Billing Integration**: Stripe-powered subscription management and invoicing
|
||||||
|
- **Usage Analytics**: Detailed reporting on storage consumption and bandwidth usage
|
||||||
|
- **Admin Console**: Centralized user management and system monitoring
|
||||||
|
- **API Access**: RESTful API for third-party integrations
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Python 3.12+
|
||||||
|
- PostgreSQL 15+
|
||||||
|
- Redis 7+
|
||||||
|
- Docker and Docker Compose (recommended)
|
||||||
|
|
||||||
|
### Quick Start with Docker
|
||||||
|
|
||||||
|
1. Clone the repository and navigate to the project directory
|
||||||
|
2. Copy the environment template:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
3. Edit `.env` with your configuration (database credentials, secrets, etc.)
|
||||||
|
4. Start the services:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
5. Access the application at `https://your-domain.com`
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pip install poetry
|
||||||
|
poetry install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set up the database:
|
||||||
|
```bash
|
||||||
|
createdb rbox
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configure environment variables in `.env`
|
||||||
|
|
||||||
|
4. Run database migrations:
|
||||||
|
```bash
|
||||||
|
poetry run rbox --migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start the application:
|
||||||
|
```bash
|
||||||
|
poetry run rbox --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
RBox uses environment variables for configuration. Key settings include:
|
||||||
|
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string
|
||||||
|
- `REDIS_URL`: Redis connection URL
|
||||||
|
- `SECRET_KEY`: JWT signing key (generate a secure random key)
|
||||||
|
- `DOMAIN_NAME`: Your domain for HTTPS certificates
|
||||||
|
- `SMTP_*`: Email server configuration
|
||||||
|
- `STRIPE_*`: Payment processing credentials
|
||||||
|
- `STORAGE_PATH`: Local storage directory path
|
||||||
|
|
||||||
|
See `.env.example` for a complete list of configuration options.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Web Interface
|
||||||
|
Access the web application through your browser. The interface provides:
|
||||||
|
- File browser with drag-and-drop upload
|
||||||
|
- Folder management and navigation
|
||||||
|
- Search and filtering capabilities
|
||||||
|
- User profile and settings
|
||||||
|
- Administrative controls (for admins)
|
||||||
|
|
||||||
|
### API Usage
|
||||||
|
RBox provides a comprehensive REST API. Example requests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Upload a file
|
||||||
|
curl -X POST "https://your-domain.com/api/files/upload" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-F "file=@example.txt"
|
||||||
|
|
||||||
|
# List files
|
||||||
|
curl -X GET "https://your-domain.com/api/files/" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
|
||||||
|
# Create a share link
|
||||||
|
curl -X POST "https://your-domain.com/api/shares/" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"file_id": 123, "expires_at": "2024-12-31T23:59:59Z"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebDAV Access
|
||||||
|
Mount RBox as a network drive using WebDAV:
|
||||||
|
```
|
||||||
|
https://your-domain.com/webdav/
|
||||||
|
```
|
||||||
|
|
||||||
|
### SFTP Access
|
||||||
|
Connect via SFTP using your RBox credentials on port 22.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
1. Set up a reverse proxy (Nginx included in docker-compose.yml)
|
||||||
|
2. Configure SSL certificates (automatic with Let's Encrypt)
|
||||||
|
3. Set up database backups
|
||||||
|
4. Configure monitoring and logging
|
||||||
|
5. Scale as needed with load balancers
|
||||||
|
|
||||||
|
### Docker Compose Services
|
||||||
|
- **app**: FastAPI application with Gunicorn
|
||||||
|
- **db**: PostgreSQL database
|
||||||
|
- **redis**: Caching and session storage
|
||||||
|
- **nginx**: Reverse proxy and static file serving
|
||||||
|
- **certbot**: SSL certificate management
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
Configure all services through the `.env` file. Sensitive data is automatically loaded and validated.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Change default secrets in production
|
||||||
|
- Enable HTTPS with valid certificates
|
||||||
|
- Regularly update dependencies
|
||||||
|
- Monitor access logs
|
||||||
|
- Implement backup strategies
|
||||||
|
- Use strong passwords and enable 2FA
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
- **Database connection errors**: Verify DATABASE_URL configuration
|
||||||
|
- **File upload failures**: Check storage permissions and quotas
|
||||||
|
- **Email not sending**: Confirm SMTP settings
|
||||||
|
- **WebDAV connection issues**: Ensure proper authentication
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
Application logs are available in the Docker containers:
|
||||||
|
```bash
|
||||||
|
docker-compose logs app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
- Check the troubleshooting section
|
||||||
|
- Review configuration examples
|
||||||
|
- Consult the API documentation at `/docs` when running
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License. See the LICENSE file for details.
|
||||||
@ -30,6 +30,8 @@ cryptography = "*"
|
|||||||
opencv-python = "*"
|
opencv-python = "*"
|
||||||
ffmpeg-python = "*"
|
ffmpeg-python = "*"
|
||||||
gunicorn = "*"
|
gunicorn = "*"
|
||||||
|
aiosmtplib = "*"
|
||||||
|
stripe = "*"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
black = "*"
|
black = "*"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..models import User_Pydantic, User
|
from ..models import User_Pydantic, User, File, Folder
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/users",
|
prefix="/users",
|
||||||
@ -10,3 +11,42 @@ router = APIRouter(
|
|||||||
@router.get("/me", response_model=User_Pydantic)
|
@router.get("/me", response_model=User_Pydantic)
|
||||||
async def read_users_me(current_user: User = Depends(get_current_user)):
|
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||||
return await User_Pydantic.from_tortoise_orm(current_user)
|
return await User_Pydantic.from_tortoise_orm(current_user)
|
||||||
|
|
||||||
|
@router.get("/me/export", response_model=Dict[str, Any])
|
||||||
|
async def export_my_data(current_user: User = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Exports all personal data associated with the current user.
|
||||||
|
Includes user profile, and metadata for all owned files and folders.
|
||||||
|
"""
|
||||||
|
user_data = await User_Pydantic.from_tortoise_orm(current_user)
|
||||||
|
|
||||||
|
files = await File.filter(owner=current_user).values(
|
||||||
|
"id", "name", "size", "created_at", "modified_at", "file_type", "parent_id"
|
||||||
|
)
|
||||||
|
folders = await Folder.filter(owner=current_user).values(
|
||||||
|
"id", "name", "created_at", "modified_at", "parent_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_profile": user_data.dict(),
|
||||||
|
"files_metadata": files,
|
||||||
|
"folders_metadata": folders,
|
||||||
|
# In a more complete implementation, other data like activity logs,
|
||||||
|
# share information, etc., would also be included.
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/me", status_code=204)
|
||||||
|
async def delete_my_account(current_user: User = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Deletes the current user's account and all associated data.
|
||||||
|
This includes all files and folders owned by the user.
|
||||||
|
"""
|
||||||
|
# Delete all files and folders owned by the user
|
||||||
|
await File.filter(owner=current_user).delete()
|
||||||
|
await Folder.filter(owner=current_user).delete()
|
||||||
|
|
||||||
|
# Finally, delete the user account
|
||||||
|
await current_user.delete()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -92,7 +92,6 @@ pytz==2025.2
|
|||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
qrcode==8.2
|
qrcode==8.2
|
||||||
RapidFuzz==3.14.3
|
RapidFuzz==3.14.3
|
||||||
-e git+https://retoor.molodetz.nl/retoor/rbox.git@1e5a6dbd5f5007c248368da57684aef075f75070#egg=rbox
|
|
||||||
redis==7.0.1
|
redis==7.0.1
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
requests-toolbelt==1.0.0
|
requests-toolbelt==1.0.0
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
.billing-dashboard {
|
.billing-dashboard {
|
||||||
padding: 2rem;
|
padding: calc(var(--spacing-unit) * 3);
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@ -8,7 +8,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-badge {
|
.subscription-badge {
|
||||||
@ -31,16 +31,16 @@
|
|||||||
.billing-cards {
|
.billing-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: calc(var(--spacing-unit) * 2.25);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.billing-card {
|
.billing-card {
|
||||||
background: white;
|
background: var(--accent-color);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: calc(var(--spacing-unit) * 2.25);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 3px var(--shadow-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.billing-card h3 {
|
.billing-card h3 {
|
||||||
@ -131,11 +131,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.invoices-section {
|
.invoices-section {
|
||||||
background: white;
|
background: var(--accent-color);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: calc(var(--spacing-unit) * 2.25);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoices-table {
|
.invoices-table {
|
||||||
@ -190,49 +190,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.payment-methods-section {
|
.payment-methods-section {
|
||||||
background: white;
|
background: var(--accent-color);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: calc(var(--spacing-unit) * 2.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #2563eb;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #6b7280;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #2563eb;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -240,20 +204,41 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: white;
|
background-color: var(--accent-color);
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
padding: calc(var(--spacing-unit) * 3);
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
max-height: 90vh;
|
width: 90%;
|
||||||
overflow-y: auto;
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 20px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-color-light);
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-bottom: var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
color: var(--secondary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-total {
|
.invoice-total {
|
||||||
@ -267,7 +252,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-billing {
|
.admin-billing {
|
||||||
padding: 2rem;
|
padding: calc(var(--spacing-unit) * 3);
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@ -275,16 +260,16 @@
|
|||||||
.stats-cards {
|
.stats-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: calc(var(--spacing-unit) * 2.25);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: white;
|
background: var(--accent-color);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: calc(var(--spacing-unit) * 2.25);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 3px var(--shadow-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card h3 {
|
.stat-card h3 {
|
||||||
@ -301,11 +286,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pricing-config-section {
|
.pricing-config-section {
|
||||||
background: white;
|
background: var(--accent-color);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: calc(var(--spacing-unit) * 2.25);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-table {
|
.pricing-table {
|
||||||
@ -342,10 +327,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.invoice-generation-section {
|
.invoice-generation-section {
|
||||||
background: white;
|
background: var(--accent-color);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: calc(var(--spacing-unit) * 2.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-gen-form {
|
.invoice-gen-form {
|
||||||
|
|||||||
@ -1,25 +1,13 @@
|
|||||||
.code-editor-overlay {
|
.code-editor-overlay {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-editor-container {
|
|
||||||
width: 90%;
|
|
||||||
height: 90%;
|
|
||||||
max-width: 1400px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
background: white;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-editor-header {
|
.code-editor-header {
|
||||||
@ -37,7 +25,7 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-editor-header .header-right {
|
.code-editor-header .preview-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -699,11 +699,15 @@ body.dark-mode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.photo-gallery {
|
.photo-gallery {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
border-radius: 8px;
|
||||||
padding: calc(var(--spacing-unit) * 2);
|
padding: calc(var(--spacing-unit) * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-header {
|
.gallery-header {
|
||||||
margin-bottom: calc(var(--spacing-unit) * 3);
|
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: calc(var(--spacing-unit) * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-header h2 {
|
.gallery-header h2 {
|
||||||
@ -761,6 +765,18 @@ body.dark-mode {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: calc(var(--spacing-unit) * 4);
|
padding: calc(var(--spacing-unit) * 4);
|
||||||
color: var(--text-color-light);
|
color: var(--text-color-light);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state::before {
|
||||||
|
content: "📁";
|
||||||
|
display: block;
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: var(--spacing-unit);
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-preview-overlay {
|
.file-preview-overlay {
|
||||||
@ -970,3 +986,59 @@ body.dark-mode {
|
|||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
background-color: rgba(0, 51, 153, 0.05);
|
background-color: rgba(0, 51, 153, 0.05);
|
||||||
}
|
}
|
||||||
|
-e
|
||||||
|
.shared-items-container {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: calc(var(--spacing-unit) * 2);
|
||||||
|
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer Styles */
|
||||||
|
.app-footer {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-color-light);
|
||||||
|
flex-shrink: 0; /* Prevent footer from shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav {
|
||||||
|
margin-bottom: var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links li {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--secondary-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -42,18 +42,18 @@ export class AdminDashboard extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="userModal" class="modal">
|
<div id="userModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<span class="close-button">×</span>
|
<span class="close-button">×</span>
|
||||||
<h3>Edit User</h3>
|
<h3>Edit User</h3>
|
||||||
<form id="userForm">
|
<form id="userForm">
|
||||||
<input type="hidden" id="userId">
|
<input type="hidden" id="userId">
|
||||||
<label for="username">Username:</label>
|
<label for="username">Username:</label>
|
||||||
<input type="text" id="username" required>
|
<input type="text" id="username" class="input-field" required>
|
||||||
<label for="email">Email:</label>
|
<label for="email">Email:</label>
|
||||||
<input type="email" id="email" required>
|
<input type="email" id="email" class="input-field" required>
|
||||||
<label for="password">Password (leave blank to keep current):</label>
|
<label for="password">Password (leave blank to keep current):</label>
|
||||||
<input type="password" id="password">
|
<input type="password" id="password" class="input-field">
|
||||||
<label for="isSuperuser">Superuser:</label>
|
<label for="isSuperuser">Superuser:</label>
|
||||||
<input type="checkbox" id="isSuperuser">
|
<input type="checkbox" id="isSuperuser">
|
||||||
<label for="isActive">Active:</label>
|
<label for="isActive">Active:</label>
|
||||||
@ -61,9 +61,9 @@ export class AdminDashboard extends HTMLElement {
|
|||||||
<label for="is2faEnabled">2FA Enabled:</label>
|
<label for="is2faEnabled">2FA Enabled:</label>
|
||||||
<input type="checkbox" id="is2faEnabled">
|
<input type="checkbox" id="is2faEnabled">
|
||||||
<label for="storageQuotaBytes">Storage Quota (Bytes):</label>
|
<label for="storageQuotaBytes">Storage Quota (Bytes):</label>
|
||||||
<input type="number" id="storageQuotaBytes">
|
<input type="number" id="storageQuotaBytes" class="input-field">
|
||||||
<label for="planType">Plan Type:</label>
|
<label for="planType">Plan Type:</label>
|
||||||
<input type="text" id="planType">
|
<input type="text" id="planType" class="input-field">
|
||||||
<button type="submit" class="button button-primary">Save</button>
|
<button type="submit" class="button button-primary">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -216,7 +216,7 @@ class BillingDashboard extends HTMLElement {
|
|||||||
|
|
||||||
<div class="payment-methods-section">
|
<div class="payment-methods-section">
|
||||||
<h3>Payment Methods</h3>
|
<h3>Payment Methods</h3>
|
||||||
<button class="btn-primary" id="addPaymentMethod">Add Payment Method</button>
|
<button class="button button-primary" id="addPaymentMethod">Add Payment Method</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -248,7 +248,7 @@ class BillingDashboard extends HTMLElement {
|
|||||||
<td><span class="invoice-status ${invoice.status}">${invoice.status}</span></td>
|
<td><span class="invoice-status ${invoice.status}">${invoice.status}</span></td>
|
||||||
<td>${invoice.due_date ? this.formatDate(invoice.due_date) : '-'}</td>
|
<td>${invoice.due_date ? this.formatDate(invoice.due_date) : '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-link" data-invoice-id="${invoice.id}">View</button>
|
<button class="button" data-invoice-id="${invoice.id}">View</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@ -309,8 +309,8 @@ class BillingDashboard extends HTMLElement {
|
|||||||
<h2>Add Payment Method</h2>
|
<h2>Add Payment Method</h2>
|
||||||
<div id="payment-element"></div>
|
<div id="payment-element"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn-primary" id="submitPayment">Add Card</button>
|
<button class="button button-primary" id="submitPayment">Add Card</button>
|
||||||
<button class="btn-secondary" id="cancelPayment">Cancel</button>
|
<button class="button" id="cancelPayment">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -394,7 +394,7 @@ class BillingDashboard extends HTMLElement {
|
|||||||
<div><strong>Total:</strong> ${this.formatCurrency(invoice.total)}</div>
|
<div><strong>Total:</strong> ${this.formatCurrency(invoice.total)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-secondary" onclick="this.closest('.modal').remove()">Close</button>
|
<button class="button" onclick="this.closest('.modal').remove()">Close</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
|
|||||||
@ -50,28 +50,32 @@ class CodeEditorView extends HTMLElement {
|
|||||||
createUI(content) {
|
createUI(content) {
|
||||||
this.innerHTML = `
|
this.innerHTML = `
|
||||||
<div class="code-editor-overlay">
|
<div class="code-editor-overlay">
|
||||||
<div class="code-editor-container">
|
<div class="code-editor-header">
|
||||||
<div class="code-editor-header">
|
<div class="header-left">
|
||||||
<div class="header-left">
|
<button class="button" id="back-btn">Back</button>
|
||||||
<button class="button" id="back-btn">Back</button>
|
<div class="preview-info">
|
||||||
<h2 class="editor-filename">${this.escapeHtml(this.file.name)}</h2>
|
<h2 class="preview-file-name">${this.escapeHtml(this.file.name)}</h2>
|
||||||
</div>
|
<p class="preview-file-info">${this.formatFileSize(this.file.size)} • ${new Date(this.file.created_at).toLocaleDateString()}</p>
|
||||||
<div class="header-right">
|
|
||||||
<button class="button button-primary" id="save-btn">Save & Close</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="code-editor-body">
|
<div class="preview-actions">
|
||||||
<textarea id="editor-textarea"></textarea>
|
<button class="button" id="download-btn">Download</button>
|
||||||
|
<button class="button button-primary" id="save-btn">Save & Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="code-editor-body">
|
||||||
|
<textarea id="editor-textarea"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const backBtn = this.querySelector('#back-btn');
|
const backBtn = this.querySelector('#back-btn');
|
||||||
const saveBtn = this.querySelector('#save-btn');
|
const saveBtn = this.querySelector('#save-btn');
|
||||||
|
const downloadBtn = this.querySelector('#download-btn');
|
||||||
|
|
||||||
backBtn.addEventListener('click', () => this.close());
|
backBtn.addEventListener('click', () => this.close());
|
||||||
saveBtn.addEventListener('click', () => this.save());
|
saveBtn.addEventListener('click', () => this.save());
|
||||||
|
downloadBtn.addEventListener('click', () => this.downloadFile());
|
||||||
|
|
||||||
document.addEventListener('keydown', this.handleKeydown.bind(this));
|
document.addEventListener('keydown', this.handleKeydown.bind(this));
|
||||||
}
|
}
|
||||||
@ -140,6 +144,33 @@ class CodeEditorView extends HTMLElement {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async downloadFile() {
|
||||||
|
try {
|
||||||
|
const blob = await api.downloadFile(this.file.id);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = this.file.name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
handleKeydown(e) {
|
handleKeydown(e) {
|
||||||
if (e.key === 'Escape' && !this.editor.getOption('readOnly')) {
|
if (e.key === 'Escape' && !this.editor.getOption('readOnly')) {
|
||||||
this.close();
|
this.close();
|
||||||
|
|||||||
146
static/js/components/cookie-consent.js
Normal file
146
static/js/components/cookie-consent.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
// static/js/components/cookie-consent.js
|
||||||
|
import app from '../app.js';
|
||||||
|
|
||||||
|
const COOKIE_CONSENT_KEY = 'rbox_cookie_consent';
|
||||||
|
|
||||||
|
export class CookieConsent extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.hasConsented = this.checkConsent();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (!this.hasConsented) {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkConsent() {
|
||||||
|
const consent = localStorage.getItem(COOKIE_CONSENT_KEY);
|
||||||
|
if (consent) {
|
||||||
|
// In a real application, you'd parse this and apply preferences
|
||||||
|
// For now, just checking if it exists
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConsent(status) {
|
||||||
|
// In a real application, 'status' would be a detailed object
|
||||||
|
// For now, a simple string 'accepted' or 'declined'
|
||||||
|
localStorage.setItem(COOKIE_CONSENT_KEY, status);
|
||||||
|
this.hasConsented = true;
|
||||||
|
this.remove(); // Remove the banner after consent
|
||||||
|
app.getLogger().info(`Cookie consent: ${status}`);
|
||||||
|
// Trigger an event for other parts of the app to react to consent change
|
||||||
|
document.dispatchEvent(new CustomEvent('cookie-consent-changed', { detail: { status } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--accent-color, #333);
|
||||||
|
color: var(--text-color-light, #eee);
|
||||||
|
padding: 15px 20px;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 10000;
|
||||||
|
font-family: var(--font-family, sans-serif);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.consent-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
.consent-message {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
}
|
||||||
|
.consent-message a {
|
||||||
|
color: var(--primary-color, #007bff);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.consent-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.consent-button {
|
||||||
|
background-color: var(--primary-color, #007bff);
|
||||||
|
color: var(--accent-color, #fff);
|
||||||
|
border: none;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.consent-button:hover {
|
||||||
|
background-color: var(--secondary-color, #0056b3);
|
||||||
|
}
|
||||||
|
.consent-button.decline {
|
||||||
|
background-color: #6c757d;
|
||||||
|
}
|
||||||
|
.consent-button.decline:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
.consent-button.customize {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--primary-color, #007bff);
|
||||||
|
color: var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
.consent-button.customize:hover {
|
||||||
|
background-color: var(--primary-color, #007bff);
|
||||||
|
color: var(--accent-color, #fff);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.consent-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.consent-buttons {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
.consent-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="consent-container">
|
||||||
|
<p class="consent-message">
|
||||||
|
We use cookies to ensure you get the best experience on our website. For more details, please read our
|
||||||
|
<a href="/static/legal/cookie_policy.md" target="_blank" rel="noopener noreferrer">Cookie Policy</a>.
|
||||||
|
</p>
|
||||||
|
<div class="consent-buttons">
|
||||||
|
<button class="consent-button accept">Accept All</button>
|
||||||
|
<button class="consent-button decline">Decline All</button>
|
||||||
|
<button class="consent-button customize">Customize</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.shadowRoot.querySelector('.consent-button.accept').addEventListener('click', () => this.setConsent('accepted'));
|
||||||
|
this.shadowRoot.querySelector('.consent-button.decline').addEventListener('click', () => this.setConsent('declined'));
|
||||||
|
this.shadowRoot.querySelector('.consent-button.customize').addEventListener('click', () => {
|
||||||
|
// For now, customize acts like accept. In a real app, this would open a modal.
|
||||||
|
app.getLogger().info('Customize cookie consent clicked. (Placeholder: acting as accept)');
|
||||||
|
this.setConsent('accepted');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('cookie-consent', CookieConsent);
|
||||||
@ -18,7 +18,9 @@ export class FileList extends HTMLElement {
|
|||||||
this.addEventListener('click', this.boundHandleClick);
|
this.addEventListener('click', this.boundHandleClick);
|
||||||
this.addEventListener('dblclick', this.boundHandleDblClick);
|
this.addEventListener('dblclick', this.boundHandleDblClick);
|
||||||
this.addEventListener('change', this.boundHandleChange);
|
this.addEventListener('change', this.boundHandleChange);
|
||||||
await this.loadContents(null);
|
if (!this.hasAttribute('data-search-mode')) {
|
||||||
|
await this.loadContents(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
@ -112,8 +114,8 @@ export class FileList extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
${totalItems === 0 ? '<p class="empty-state">No files found.</p>' : ''}
|
||||||
<div class="file-grid">
|
<div class="file-grid">
|
||||||
${totalItems === 0 ? '<p class="empty-state">No files found.</p>' : ''}
|
|
||||||
${this.folders.map(folder => this.renderFolder(folder)).join('')}
|
${this.folders.map(folder => this.renderFolder(folder)).join('')}
|
||||||
${this.files.map(file => this.renderFile(file)).join('')}
|
${this.files.map(file => this.renderFile(file)).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -39,6 +39,9 @@ export class LoginView extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
this.querySelector('#login-error').style.display = 'none';
|
||||||
|
this.querySelector('#register-error').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
attachListeners() {
|
attachListeners() {
|
||||||
@ -76,14 +79,18 @@ export class LoginView extends HTMLElement {
|
|||||||
const password = formData.get('password');
|
const password = formData.get('password');
|
||||||
const errorDiv = this.querySelector('#login-error');
|
const errorDiv = this.querySelector('#login-error');
|
||||||
|
|
||||||
|
errorDiv.style.display = 'none';
|
||||||
|
errorDiv.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info('Login attempt started', { username });
|
logger.info('Login attempt started', { action: 'USER_LOGIN_ATTEMPT', username });
|
||||||
await api.login(username, password);
|
await api.login(username, password);
|
||||||
logger.info('Login successful, dispatching auth-success event');
|
logger.info('Login successful', { action: 'USER_LOGIN_SUCCESS', username });
|
||||||
this.dispatchEvent(new CustomEvent('auth-success'));
|
this.dispatchEvent(new CustomEvent('auth-success'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Login failed', { username, error: error.message });
|
logger.error('Login failed', { action: 'USER_LOGIN_FAILURE', username, error: error.message });
|
||||||
errorDiv.textContent = error.message;
|
errorDiv.textContent = error.message;
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,14 +103,18 @@ export class LoginView extends HTMLElement {
|
|||||||
const password = formData.get('password');
|
const password = formData.get('password');
|
||||||
const errorDiv = this.querySelector('#register-error');
|
const errorDiv = this.querySelector('#register-error');
|
||||||
|
|
||||||
|
errorDiv.style.display = 'none';
|
||||||
|
errorDiv.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info('Registration attempt started', { username, email });
|
logger.info('Registration attempt started', { action: 'USER_REGISTER_ATTEMPT', username, email });
|
||||||
await api.register(username, email, password);
|
await api.register(username, email, password);
|
||||||
logger.info('Registration successful, dispatching auth-success event');
|
logger.info('Registration successful', { action: 'USER_REGISTER_SUCCESS', username, email });
|
||||||
this.dispatchEvent(new CustomEvent('auth-success'));
|
this.dispatchEvent(new CustomEvent('auth-success'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Registration failed', { username, email, error: error.message });
|
logger.error('Registration failed', { action: 'USER_REGISTER_FAILURE', username, email, error: error.message });
|
||||||
errorDiv.textContent = error.message;
|
errorDiv.textContent = error.message;
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,10 +30,19 @@ class PhotoGallery extends HTMLElement {
|
|||||||
async renderPhotos() {
|
async renderPhotos() {
|
||||||
const grid = this.querySelector('.gallery-grid');
|
const grid = this.querySelector('.gallery-grid');
|
||||||
if (this.photos.length === 0) {
|
if (this.photos.length === 0) {
|
||||||
grid.innerHTML = '<p class="empty-state">No photos found.</p>';
|
grid.innerHTML = '';
|
||||||
|
const header = this.querySelector('.gallery-header');
|
||||||
|
const empty = document.createElement('p');
|
||||||
|
empty.className = 'empty-state';
|
||||||
|
empty.textContent = 'No photos found.';
|
||||||
|
header.insertAdjacentElement('afterend', empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove any existing empty-state
|
||||||
|
const existingEmpty = this.querySelector('.empty-state');
|
||||||
|
if (existingEmpty) existingEmpty.remove();
|
||||||
|
|
||||||
grid.innerHTML = this.photos.map(photo => `
|
grid.innerHTML = this.photos.map(photo => `
|
||||||
<div class="photo-item" data-file-id="${photo.id}">
|
<div class="photo-item" data-file-id="${photo.id}">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import './shared-items.js';
|
|||||||
import './billing-dashboard.js';
|
import './billing-dashboard.js';
|
||||||
import './admin-billing.js';
|
import './admin-billing.js';
|
||||||
import './code-editor-view.js';
|
import './code-editor-view.js';
|
||||||
|
import './cookie-consent.js';
|
||||||
|
import './user-settings.js'; // Import the new user settings component
|
||||||
import { shortcuts } from '../shortcuts.js';
|
import { shortcuts } from '../shortcuts.js';
|
||||||
|
|
||||||
const api = app.getAPI();
|
const api = app.getAPI();
|
||||||
@ -28,6 +30,7 @@ export class RBoxApp extends HTMLElement {
|
|||||||
this.navigationStack = [];
|
this.navigationStack = [];
|
||||||
this.boundHandlePopState = this.handlePopState.bind(this);
|
this.boundHandlePopState = this.handlePopState.bind(this);
|
||||||
this.popstateAttached = false;
|
this.popstateAttached = false;
|
||||||
|
this.currentSearchId = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
@ -125,6 +128,7 @@ export class RBoxApp extends HTMLElement {
|
|||||||
<li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li>
|
<li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li>
|
||||||
<li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li>
|
<li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li>
|
||||||
<li><a href="#" class="nav-link" data-view="billing">Billing</a></li>
|
<li><a href="#" class="nav-link" data-view="billing">Billing</a></li>
|
||||||
|
<li><a href="#" class="nav-link" data-view="user-settings">User Settings</a></li>
|
||||||
${this.user && this.user.is_superuser ? `<li><a href="#" class="nav-link" data-view="admin">Admin Dashboard</a></li>` : ''}
|
${this.user && this.user.is_superuser ? `<li><a href="#" class="nav-link" data-view="admin">Admin Dashboard</a></li>` : ''}
|
||||||
${this.user && this.user.is_superuser ? `<li><a href="#" class="nav-link" data-view="admin-billing">Admin Billing</a></li>` : ''}
|
${this.user && this.user.is_superuser ? `<li><a href="#" class="nav-link" data-view="admin-billing">Admin Billing</a></li>` : ''}
|
||||||
</ul>
|
</ul>
|
||||||
@ -144,7 +148,24 @@ export class RBoxApp extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<share-modal></share-modal>
|
<share-modal></share-modal>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<nav class="footer-nav">
|
||||||
|
<ul class="footer-links">
|
||||||
|
<li><a href="/static/legal/privacy_policy.md" target="_blank" rel="noopener noreferrer">Privacy Policy</a></li>
|
||||||
|
<li><a href="/static/legal/data_processing_agreement.md" target="_blank" rel="noopener noreferrer">Data Processing Agreement</a></li>
|
||||||
|
<li><a href="/static/legal/terms_of_service.md" target="_blank" rel="noopener noreferrer">Terms of Service</a></li>
|
||||||
|
<li><a href="/static/legal/cookie_policy.md" target="_blank" rel="noopener noreferrer">Cookie Policy</a></li>
|
||||||
|
<li><a href="/static/legal/security_policy.md" target="_blank" rel="noopener noreferrer">Security Policy</a></li>
|
||||||
|
<li><a href="/static/legal/compliance_statement.md" target="_blank" rel="noopener noreferrer">Compliance Statement</a></li>
|
||||||
|
<li><a href="/static/legal/data_portability_deletion_policy.md" target="_blank" rel="noopener noreferrer">Data Portability & Deletion</a></li>
|
||||||
|
<li><a href="/static/legal/contact_complaint_mechanism.md" target="_blank" rel="noopener noreferrer">Contact & Complaints</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<p class="footer-text">© ${new Date().getFullYear()} RBox Cloud Storage. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
<cookie-consent></cookie-consent>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.initializeNavigation();
|
this.initializeNavigation();
|
||||||
@ -357,6 +378,7 @@ export class RBoxApp extends HTMLElement {
|
|||||||
|
|
||||||
attachListeners() {
|
attachListeners() {
|
||||||
this.querySelector('#logout-btn')?.addEventListener('click', () => {
|
this.querySelector('#logout-btn')?.addEventListener('click', () => {
|
||||||
|
logger.info('User logout initiated', { action: 'USER_LOGOUT' });
|
||||||
api.logout();
|
api.logout();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -567,8 +589,10 @@ export class RBoxApp extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async performSearch(query) {
|
async performSearch(query) {
|
||||||
|
const searchId = ++this.currentSearchId;
|
||||||
try {
|
try {
|
||||||
const files = await api.searchFiles(query);
|
const files = await api.searchFiles(query);
|
||||||
|
if (searchId !== this.currentSearchId) return;
|
||||||
const mainContent = this.querySelector('#main-content');
|
const mainContent = this.querySelector('#main-content');
|
||||||
mainContent.innerHTML = `
|
mainContent.innerHTML = `
|
||||||
<div class="search-results">
|
<div class="search-results">
|
||||||
@ -580,7 +604,9 @@ export class RBoxApp extends HTMLElement {
|
|||||||
fileList.setFiles(files);
|
fileList.setFiles(files);
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error);
|
if (searchId === this.currentSearchId) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -634,6 +660,10 @@ export class RBoxApp extends HTMLElement {
|
|||||||
mainContent.innerHTML = '<billing-dashboard></billing-dashboard>';
|
mainContent.innerHTML = '<billing-dashboard></billing-dashboard>';
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
break;
|
break;
|
||||||
|
case 'user-settings':
|
||||||
|
mainContent.innerHTML = '<user-settings></user-settings>';
|
||||||
|
this.attachListeners();
|
||||||
|
break;
|
||||||
case 'admin-billing':
|
case 'admin-billing':
|
||||||
mainContent.innerHTML = '<admin-billing></admin-billing>';
|
mainContent.innerHTML = '<admin-billing></admin-billing>';
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
|||||||
@ -32,7 +32,9 @@ export class SharedItems extends HTMLElement {
|
|||||||
if (this.myShares.length === 0) {
|
if (this.myShares.length === 0) {
|
||||||
this.innerHTML = `
|
this.innerHTML = `
|
||||||
<div class="shared-items-container">
|
<div class="shared-items-container">
|
||||||
<h2>Shared Items</h2>
|
<div class="file-list-header">
|
||||||
|
<h2>Shared Items</h2>
|
||||||
|
</div>
|
||||||
<p class="empty-state">No shared items found.</p>
|
<p class="empty-state">No shared items found.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -41,7 +43,9 @@ export class SharedItems extends HTMLElement {
|
|||||||
|
|
||||||
this.innerHTML = `
|
this.innerHTML = `
|
||||||
<div class="shared-items-container">
|
<div class="shared-items-container">
|
||||||
<h2>Shared Items</h2>
|
<div class="file-list-header">
|
||||||
|
<h2>Shared Items</h2>
|
||||||
|
</div>
|
||||||
<div class="share-list">
|
<div class="share-list">
|
||||||
${this.myShares.map(share => this.renderShare(share)).join('')}
|
${this.myShares.map(share => this.renderShare(share)).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
117
static/js/components/user-settings.js
Normal file
117
static/js/components/user-settings.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
// static/js/components/user-settings.js
|
||||||
|
import app from '../app.js';
|
||||||
|
|
||||||
|
const api = app.getAPI();
|
||||||
|
const logger = app.getLogger();
|
||||||
|
|
||||||
|
export class UserSettings extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.boundHandleClick = this.handleClick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.render();
|
||||||
|
this.addEventListener('click', this.boundHandleClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.removeEventListener('click', this.boundHandleClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="user-settings-container">
|
||||||
|
<h2>User Settings</h2>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Data Management</h3>
|
||||||
|
<p>You can export a copy of your personal data or delete your account.</p>
|
||||||
|
<button id="exportDataBtn" class="button button-primary">Export My Data</button>
|
||||||
|
<button id="deleteAccountBtn" class="button button-danger">Delete My Account</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add more settings sections here later -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleClick(event) {
|
||||||
|
if (event.target.id === 'exportDataBtn') {
|
||||||
|
await this.exportUserData();
|
||||||
|
} else if (event.target.id === 'deleteAccountBtn') {
|
||||||
|
await this.deleteAccount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportUserData() {
|
||||||
|
try {
|
||||||
|
logger.info('Initiating data export...', { action: 'USER_DATA_EXPORT_ATTEMPT' });
|
||||||
|
const response = await fetch('/api/users/me/export', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${api.getToken()}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const filename = `rbox_user_data_${new Date().toISOString().slice(0,10)}.json`;
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
logger.info('User data exported successfully.', { action: 'USER_DATA_EXPORT_SUCCESS' });
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Your data has been exported successfully!', type: 'success' }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to export user data:', { action: 'USER_DATA_EXPORT_FAILURE', error: error.message });
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: `Failed to export data: ${error.message}`, type: 'error' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount() {
|
||||||
|
if (!confirm('Are you absolutely sure you want to delete your account? This action cannot be undone and all your data will be permanently lost.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.warn('Initiating account deletion...', { action: 'USER_ACCOUNT_DELETE_ATTEMPT' });
|
||||||
|
const response = await fetch('/api/users/me', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${api.getToken()}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Account deleted successfully. Logging out...', { action: 'USER_ACCOUNT_DELETE_SUCCESS' });
|
||||||
|
api.logout(); // Clear token and redirect to login
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Your account has been successfully deleted.', type: 'success' }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete account:', { action: 'USER_ACCOUNT_DELETE_FAILURE', error: error.message });
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: `Failed to delete account: ${error.message}`, type: 'error' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('user-settings', UserSettings);
|
||||||
181
tests/e2e/test_admin.py
Normal file
181
tests/e2e/test_admin.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from playwright.async_api import expect
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestAdmin:
|
||||||
|
|
||||||
|
async def test_01_admin_login(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'adminuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'adminpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Admin should see admin nav links
|
||||||
|
await expect(page.locator('a.nav-link[data-view="admin"]')).to_be_visible()
|
||||||
|
await expect(page.locator('a.nav-link[data-view="admin-billing"]')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_02_navigate_to_admin_dashboard(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'adminuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'adminpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="admin"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('admin-dashboard')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_03_view_user_management(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'adminuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'adminpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="admin"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show user list
|
||||||
|
await expect(page.locator('.user-list')).to_be_visible()
|
||||||
|
await expect(page.locator('text=e2etestuser')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_04_view_system_statistics(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'adminuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'adminpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="admin"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show system stats
|
||||||
|
await expect(page.locator('.system-stats')).to_be_visible()
|
||||||
|
await expect(page.locator('.stat-item')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_05_manage_user_permissions(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'adminuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'adminpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="admin"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Click on user to manage
|
||||||
|
await page.click('.user-item:has-text("e2etestuser")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Should show user details modal
|
||||||
|
await expect(page.locator('.user-details-modal')).to_be_visible()
|
||||||
|
|
||||||
|
# Change permissions
|
||||||
|
await page.select_option('select[name="role"]', 'moderator')
|
||||||
|
await page.click('button:has-text("Save")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
async def test_06_view_storage_usage(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'adminuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'adminpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="admin"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show storage usage
|
||||||
|
await expect(page.locator('.storage-usage')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_07_navigate_to_admin_billing(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'adminuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'adminpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="admin-billing"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('admin-billing')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_08_view_billing_statistics(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'adminuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'adminpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="admin-billing"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show billing stats
|
||||||
|
await expect(page.locator('.billing-stats')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_09_manage_pricing(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'adminuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'adminpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="admin-billing"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Click pricing management
|
||||||
|
await page.click('button:has-text("Manage Pricing")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Should show pricing form
|
||||||
|
await expect(page.locator('.pricing-form')).to_be_visible()
|
||||||
|
|
||||||
|
# Update a price
|
||||||
|
await page.fill('input[name="storage_per_gb_month"]', '0.005')
|
||||||
|
await page.click('button:has-text("Update")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
async def test_10_generate_invoice(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'adminuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'adminpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="admin-billing"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Click generate invoice
|
||||||
|
await page.click('button:has-text("Generate Invoice")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Select user
|
||||||
|
await page.select_option('select[name="user"]', 'e2etestuser')
|
||||||
|
await page.click('button:has-text("Generate")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show success message
|
||||||
|
await expect(page.locator('text=Invoice generated')).to_be_visible()
|
||||||
170
tests/e2e/test_auth_flow.py
Normal file
170
tests/e2e/test_auth_flow.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from playwright.async_api import expect
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestAuthFlow:
|
||||||
|
|
||||||
|
async def test_01_user_registration(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Click Sign Up
|
||||||
|
await page.click('text=Sign Up')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Fill registration form
|
||||||
|
await page.fill('#register-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#register-form input[name="email"]', 'e2etestuser@example.com')
|
||||||
|
await page.fill('#register-form input[name="password"]', 'testpassword123')
|
||||||
|
|
||||||
|
# Submit registration
|
||||||
|
await page.click('#register-form button[type="submit"]')
|
||||||
|
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
await expect(page.locator('text=My Files')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_02_user_login(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Unregister service worker to avoid interference
|
||||||
|
await page.evaluate("""
|
||||||
|
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||||
|
for(let registration of registrations) {
|
||||||
|
registration.unregister();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
""")
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Fill login form
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
|
||||||
|
# Submit login
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
await expect(page.locator('text=My Files')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_03_navigate_to_files_view(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Files view should be active by default
|
||||||
|
await expect(page.locator('a.nav-link.active[data-view="files"]')).to_be_visible()
|
||||||
|
await expect(page.locator('file-list')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_04_navigate_to_photos_view(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="photos"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('a.nav-link.active[data-view="photos"]')).to_be_visible()
|
||||||
|
await expect(page.locator('photo-gallery')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_05_navigate_to_shared_view(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="shared"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('a.nav-link.active[data-view="shared"]')).to_be_visible()
|
||||||
|
await expect(page.locator('shared-items')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_06_navigate_to_starred_view(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="starred"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('a.nav-link.active[data-view="starred"]')).to_be_visible()
|
||||||
|
await expect(page.locator('starred-items')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_07_navigate_to_recent_view(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="recent"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('a.nav-link.active[data-view="recent"]')).to_be_visible()
|
||||||
|
await expect(page.locator('recent-files')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_08_navigate_to_deleted_view(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="deleted"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('a.nav-link.active[data-view="deleted"]')).to_be_visible()
|
||||||
|
await expect(page.locator('deleted-files')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_09_navigate_to_billing_view(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="billing"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('a.nav-link.active[data-view="billing"]')).to_be_visible()
|
||||||
|
await expect(page.locator('billing-dashboard')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_10_user_logout(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Click logout
|
||||||
|
await page.click('#logout-btn')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should be back to login
|
||||||
|
await expect(page.locator('#login-form')).to_be_visible()
|
||||||
222
tests/e2e/test_file_management.py
Normal file
222
tests/e2e/test_file_management.py
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from playwright.async_api import expect
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestFileManagement:
|
||||||
|
|
||||||
|
async def test_01_upload_file(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Upload a file
|
||||||
|
await page.set_input_files('input[type="file"]', {
|
||||||
|
'name': 'test-file.txt',
|
||||||
|
'mimeType': 'text/plain',
|
||||||
|
'buffer': b'This is a test file for e2e testing.'
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.click('button:has-text("Upload")')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await expect(page.locator('text=test-file.txt')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_02_create_folder(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Click create folder button
|
||||||
|
await page.click('button:has-text("New Folder")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Fill folder name
|
||||||
|
await page.fill('input[placeholder="Folder name"]', 'Test Folder')
|
||||||
|
await page.click('button:has-text("Create")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('text=Test Folder')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_03_navigate_into_folder(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Click on the folder
|
||||||
|
await page.click('text=Test Folder')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should be in the folder, breadcrumb should show it
|
||||||
|
await expect(page.locator('.breadcrumb')).to_contain_text('Test Folder')
|
||||||
|
|
||||||
|
async def test_04_upload_file_in_folder(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Navigate to folder
|
||||||
|
await page.click('text=Test Folder')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Upload file in folder
|
||||||
|
await page.set_input_files('input[type="file"]', {
|
||||||
|
'name': 'folder-file.txt',
|
||||||
|
'mimeType': 'text/plain',
|
||||||
|
'buffer': b'This file is in a folder.'
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.click('button:has-text("Upload")')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await expect(page.locator('text=folder-file.txt')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_05_navigate_back_from_folder(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Navigate to folder
|
||||||
|
await page.click('text=Test Folder')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Click breadcrumb to go back
|
||||||
|
await page.click('.breadcrumb a:first-child')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should be back in root
|
||||||
|
await expect(page.locator('text=Test Folder')).to_be_visible()
|
||||||
|
await expect(page.locator('text=test-file.txt')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_06_select_and_delete_file(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Select file
|
||||||
|
await page.click('.file-item:has-text("test-file.txt") .file-checkbox')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Click delete
|
||||||
|
await page.click('button:has-text("Delete")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Confirm delete
|
||||||
|
await page.click('button:has-text("Confirm")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# File should be gone
|
||||||
|
await expect(page.locator('text=test-file.txt')).not_to_be_visible()
|
||||||
|
|
||||||
|
async def test_07_restore_deleted_file(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Go to deleted files
|
||||||
|
await page.click('a.nav-link[data-view="deleted"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Select deleted file
|
||||||
|
await page.click('.file-item:has-text("test-file.txt") .file-checkbox')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Click restore
|
||||||
|
await page.click('button:has-text("Restore")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Go back to files
|
||||||
|
await page.click('a.nav-link[data-view="files"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# File should be back
|
||||||
|
await expect(page.locator('text=test-file.txt')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_08_rename_folder(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Right-click on folder or use context menu
|
||||||
|
await page.click('.folder-item:has-text("Test Folder")', button='right')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Click rename
|
||||||
|
await page.click('text=Rename')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Fill new name
|
||||||
|
await page.fill('input[placeholder="New name"]', 'Renamed Folder')
|
||||||
|
await page.click('button:has-text("Rename")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('text=Renamed Folder')).to_be_visible()
|
||||||
|
await expect(page.locator('text=Test Folder')).not_to_be_visible()
|
||||||
|
|
||||||
|
async def test_09_star_file(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Click star on file
|
||||||
|
await page.click('.file-item:has-text("test-file.txt") .star-btn')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Go to starred
|
||||||
|
await page.click('a.nav-link[data-view="starred"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('text=test-file.txt')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_10_search_files(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Type in search
|
||||||
|
await page.fill('#search-input', 'test-file')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show search results
|
||||||
|
await expect(page.locator('.search-results')).to_be_visible()
|
||||||
|
await expect(page.locator('text=test-file.txt')).to_be_visible()
|
||||||
227
tests/e2e/test_photo_gallery.py
Normal file
227
tests/e2e/test_photo_gallery.py
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from playwright.async_api import expect
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestPhotoGallery:
|
||||||
|
|
||||||
|
async def test_01_upload_image_file(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Upload an image file
|
||||||
|
await page.set_input_files('input[type="file"]', {
|
||||||
|
'name': 'test-image.jpg',
|
||||||
|
'mimeType': 'image/jpeg',
|
||||||
|
'buffer': b'fake image data' # In real test, use actual image bytes
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.click('button:has-text("Upload")')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await expect(page.locator('text=test-image.jpg')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_02_navigate_to_photo_gallery(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="photos"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('photo-gallery')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_03_view_image_in_gallery(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="photos"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show uploaded image
|
||||||
|
await expect(page.locator('.photo-item')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_04_click_on_photo_to_preview(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="photos"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Click on photo
|
||||||
|
await page.click('.photo-item img')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should open preview
|
||||||
|
await expect(page.locator('file-preview')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_05_close_photo_preview(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="photos"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Click on photo
|
||||||
|
await page.click('.photo-item img')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Close preview
|
||||||
|
await page.click('.preview-close-btn')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Preview should be closed
|
||||||
|
await expect(page.locator('file-preview')).not_to_be_visible()
|
||||||
|
|
||||||
|
async def test_06_upload_multiple_images(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Upload multiple images
|
||||||
|
await page.set_input_files('input[type="file"]', [
|
||||||
|
{
|
||||||
|
'name': 'image1.jpg',
|
||||||
|
'mimeType': 'image/jpeg',
|
||||||
|
'buffer': b'fake image 1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'image2.png',
|
||||||
|
'mimeType': 'image/png',
|
||||||
|
'buffer': b'fake image 2'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
await page.click('button:has-text("Upload")')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Go to gallery
|
||||||
|
await page.click('a.nav-link[data-view="photos"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show multiple photos
|
||||||
|
photo_count = await page.locator('.photo-item').count()
|
||||||
|
assert photo_count >= 2
|
||||||
|
|
||||||
|
async def test_07_filter_photos_by_date(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="photos"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Click date filter
|
||||||
|
await page.click('.date-filter-btn')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Select today
|
||||||
|
await page.click('text=Today')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should filter photos
|
||||||
|
await expect(page.locator('.photo-item')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_08_search_photos(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="photos"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Type in gallery search
|
||||||
|
await page.fill('.gallery-search-input', 'image1')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show filtered results
|
||||||
|
await expect(page.locator('text=image1.jpg')).to_be_visible()
|
||||||
|
await expect(page.locator('text=image2.png')).not_to_be_visible()
|
||||||
|
|
||||||
|
async def test_09_create_album(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="photos"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Click create album
|
||||||
|
await page.click('button:has-text("Create Album")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Fill album name
|
||||||
|
await page.fill('input[name="album-name"]', 'Test Album')
|
||||||
|
await page.click('button:has-text("Create")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show album
|
||||||
|
await expect(page.locator('text=Test Album')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_10_add_photos_to_album(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
await page.click('a.nav-link[data-view="photos"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Select photos
|
||||||
|
await page.click('.photo-item .photo-checkbox', { force: True })
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Click add to album
|
||||||
|
await page.click('button:has-text("Add to Album")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Select album
|
||||||
|
await page.click('text=Test Album')
|
||||||
|
await page.click('button:has-text("Add")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show success
|
||||||
|
await expect(page.locator('text=Photos added to album')).to_be_visible()
|
||||||
207
tests/e2e/test_sharing.py
Normal file
207
tests/e2e/test_sharing.py
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from playwright.async_api import expect
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestSharing:
|
||||||
|
|
||||||
|
async def test_01_share_file_with_user(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Select file
|
||||||
|
await page.click('.file-item:has-text("test-file.txt") .file-checkbox')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Click share
|
||||||
|
await page.click('button:has-text("Share")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Share modal should appear
|
||||||
|
await expect(page.locator('share-modal')).to_be_visible()
|
||||||
|
|
||||||
|
# Fill share form
|
||||||
|
await page.fill('input[placeholder="Username to share with"]', 'billingtest')
|
||||||
|
await page.select_option('select[name="permission"]', 'read')
|
||||||
|
await page.click('button:has-text("Share")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Modal should close
|
||||||
|
await expect(page.locator('share-modal')).not_to_be_visible()
|
||||||
|
|
||||||
|
async def test_02_view_shared_items_as_recipient(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'billingtest')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'password123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Go to shared items
|
||||||
|
await page.click('a.nav-link[data-view="shared"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('text=test-file.txt')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_03_download_shared_file(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'billingtest')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'password123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Go to shared items
|
||||||
|
await page.click('a.nav-link[data-view="shared"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Click download on shared file
|
||||||
|
await page.click('.file-item:has-text("test-file.txt") .download-btn')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should trigger download (can't easily test actual download in e2e)
|
||||||
|
|
||||||
|
async def test_04_create_public_link(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Select file
|
||||||
|
await page.click('.file-item:has-text("test-file.txt") .file-checkbox')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Click share
|
||||||
|
await page.click('button:has-text("Share")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Click create public link
|
||||||
|
await page.click('button:has-text("Create Public Link")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should show link
|
||||||
|
await expect(page.locator('.public-link')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_05_access_public_link(self, page, base_url):
|
||||||
|
# First get the public link from previous test
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Get the public link
|
||||||
|
await page.click('.file-item:has-text("test-file.txt") .file-checkbox')
|
||||||
|
await page.click('button:has-text("Share")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
link_element = page.locator('.public-link input')
|
||||||
|
public_url = await link_element.get_attribute('value')
|
||||||
|
|
||||||
|
# Logout
|
||||||
|
await page.click('#logout-btn')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Access public link
|
||||||
|
await page.goto(public_url)
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Should be able to view/download the file
|
||||||
|
await expect(page.locator('text=test-file.txt')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_06_revoke_share(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Go to shared items view (as owner)
|
||||||
|
await page.click('a.nav-link[data-view="shared"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Find the share and revoke
|
||||||
|
await page.click('.share-item .revoke-btn')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Confirm revoke
|
||||||
|
await page.click('button:has-text("Confirm")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Share should be gone
|
||||||
|
await expect(page.locator('.share-item')).not_to_be_visible()
|
||||||
|
|
||||||
|
async def test_07_share_folder(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'e2etestuser')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'testpassword123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Select folder
|
||||||
|
await page.click('.folder-item:has-text("Renamed Folder") .folder-checkbox')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Click share
|
||||||
|
await page.click('button:has-text("Share")')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Share modal should appear
|
||||||
|
await expect(page.locator('share-modal')).to_be_visible()
|
||||||
|
|
||||||
|
# Fill share form
|
||||||
|
await page.fill('input[placeholder="Username to share with"]', 'billingtest')
|
||||||
|
await page.select_option('select[name="permission"]', 'read')
|
||||||
|
await page.click('button:has-text("Share")')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
async def test_08_view_shared_folder_as_recipient(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'billingtest')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'password123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Go to shared items
|
||||||
|
await page.click('a.nav-link[data-view="shared"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('text=Renamed Folder')).to_be_visible()
|
||||||
|
|
||||||
|
async def test_09_navigate_into_shared_folder(self, page, base_url):
|
||||||
|
await page.goto(f"{base_url}/")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
await page.fill('#login-form input[name="username"]', 'billingtest')
|
||||||
|
await page.fill('#login-form input[name="password"]', 'password123')
|
||||||
|
await page.click('#login-form button[type="submit"]')
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Go to shared items
|
||||||
|
await page.click('a.nav-link[data-view="shared"]')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Click on shared folder
|
||||||
|
await page.click('text=Renamed Folder')
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
# Should see the file inside
|
||||||
|
await expect(page.locator('text=folder-file.txt')).to_be_visible()
|
||||||
Loading…
Reference in New Issue
Block a user