mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-27 23:46:37 +01:00
Compare commits
7 Commits
katos/test
...
tonya/coll
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7f06210c | ||
|
|
89692b4603 | ||
|
|
c444117a1d | ||
|
|
397aed47a8 | ||
|
|
9e8172657b | ||
|
|
ab57085f8b | ||
|
|
12d6b17318 |
259
.github/scripts/upgrade-test/README.md
vendored
259
.github/scripts/upgrade-test/README.md
vendored
@@ -1,259 +0,0 @@
|
||||
# HomeBox Upgrade Testing Workflow
|
||||
|
||||
This document describes the automated upgrade testing workflow for HomeBox.
|
||||
|
||||
## Overview
|
||||
|
||||
The upgrade test workflow is designed to ensure data integrity and functionality when upgrading HomeBox from one version to another. It automatically:
|
||||
|
||||
1. Deploys a stable version of HomeBox
|
||||
2. Creates test data (users, items, locations, labels, notifiers, attachments)
|
||||
3. Upgrades to the latest version from the main branch
|
||||
4. Verifies all data and functionality remain intact
|
||||
|
||||
## Workflow File
|
||||
|
||||
**Location**: `.github/workflows/upgrade-test.yaml`
|
||||
|
||||
## Trigger Conditions
|
||||
|
||||
The workflow runs:
|
||||
- **Daily**: Automatically at 2 AM UTC (via cron schedule)
|
||||
- **Manual**: Can be triggered manually via GitHub Actions UI
|
||||
- **On Push**: When changes are made to the workflow files or test scripts
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Environment Setup
|
||||
- Pulls the latest stable HomeBox Docker image from GHCR
|
||||
- Starts the application with test configuration
|
||||
- Ensures the service is healthy and ready
|
||||
|
||||
### 2. Data Creation
|
||||
|
||||
The workflow creates comprehensive test data using the `create-test-data.sh` script:
|
||||
|
||||
#### Users and Groups
|
||||
- **Group 1**: 5 users (user1@homebox.test through user5@homebox.test)
|
||||
- **Group 2**: 2 users (user6@homebox.test and user7@homebox.test)
|
||||
- All users have password: `TestPassword123!`
|
||||
|
||||
#### Locations
|
||||
- **Group 1**: Living Room, Garage
|
||||
- **Group 2**: Home Office
|
||||
|
||||
#### Labels
|
||||
- **Group 1**: Electronics, Important
|
||||
- **Group 2**: Work Equipment
|
||||
|
||||
#### Items
|
||||
- **Group 1**: 5 items (Laptop Computer, Power Drill, TV Remote, Tool Box, Coffee Maker)
|
||||
- **Group 2**: 2 items (Monitor, Keyboard)
|
||||
|
||||
#### Attachments
|
||||
- Multiple attachments added to various items (receipts, manuals, warranties)
|
||||
|
||||
#### Notifiers
|
||||
- **Group 1**: Test notifier named "TESTING"
|
||||
|
||||
### 3. Upgrade Process
|
||||
|
||||
1. Stops the stable version container
|
||||
2. Builds a fresh image from the current main branch
|
||||
3. Copies the database to a new location
|
||||
4. Starts the new version with the existing data
|
||||
|
||||
### 4. Verification Tests
|
||||
|
||||
The Playwright test suite (`upgrade-verification.spec.ts`) verifies:
|
||||
|
||||
- ✅ **User Authentication**: All 7 users can log in with their credentials
|
||||
- ✅ **Data Persistence**: All items, locations, and labels are present
|
||||
- ✅ **Attachments**: File attachments are correctly associated with items
|
||||
- ✅ **Notifiers**: The "TESTING" notifier is still configured
|
||||
- ✅ **UI Functionality**: Version display, theme switching work correctly
|
||||
- ✅ **Data Isolation**: Groups can only see their own data
|
||||
|
||||
## Test Data File
|
||||
|
||||
The setup script generates a JSON file at `/tmp/test-users.json` containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"email": "user1@homebox.test",
|
||||
"password": "TestPassword123!",
|
||||
"token": "...",
|
||||
"group": "1"
|
||||
},
|
||||
...
|
||||
],
|
||||
"locations": {
|
||||
"group1": ["location-id-1", "location-id-2"],
|
||||
"group2": ["location-id-3"]
|
||||
},
|
||||
"labels": {...},
|
||||
"items": {...},
|
||||
"notifiers": {...}
|
||||
}
|
||||
```
|
||||
|
||||
This file is used by the Playwright tests to verify data integrity.
|
||||
|
||||
## Scripts
|
||||
|
||||
### create-test-data.sh
|
||||
|
||||
**Location**: `.github/scripts/upgrade-test/create-test-data.sh`
|
||||
|
||||
**Purpose**: Creates all test data via the HomeBox REST API
|
||||
|
||||
**Environment Variables**:
|
||||
- `HOMEBOX_URL`: Base URL of the HomeBox instance (default: http://localhost:7745)
|
||||
- `TEST_DATA_FILE`: Path to output JSON file (default: /tmp/test-users.json)
|
||||
|
||||
**Requirements**:
|
||||
- `curl`: For API calls
|
||||
- `jq`: For JSON processing
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
export HOMEBOX_URL=http://localhost:7745
|
||||
./.github/scripts/upgrade-test/create-test-data.sh
|
||||
```
|
||||
|
||||
## Running Tests Locally
|
||||
|
||||
To run the upgrade tests locally:
|
||||
|
||||
### Prerequisites
|
||||
```bash
|
||||
# Install dependencies
|
||||
sudo apt-get install -y jq curl docker.io
|
||||
|
||||
# Install pnpm and Playwright
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
```
|
||||
|
||||
### Run the test
|
||||
```bash
|
||||
# Start stable version
|
||||
docker run -d \
|
||||
--name homebox-test \
|
||||
-p 7745:7745 \
|
||||
-e HBOX_OPTIONS_ALLOW_REGISTRATION=true \
|
||||
-v /tmp/homebox-data:/data \
|
||||
ghcr.io/sysadminsmedia/homebox:latest
|
||||
|
||||
# Wait for startup
|
||||
sleep 10
|
||||
|
||||
# Create test data
|
||||
export HOMEBOX_URL=http://localhost:7745
|
||||
./.github/scripts/upgrade-test/create-test-data.sh
|
||||
|
||||
# Stop container
|
||||
docker stop homebox-test
|
||||
docker rm homebox-test
|
||||
|
||||
# Build new version
|
||||
docker build -t homebox:test .
|
||||
|
||||
# Start new version with existing data
|
||||
docker run -d \
|
||||
--name homebox-test \
|
||||
-p 7745:7745 \
|
||||
-e HBOX_OPTIONS_ALLOW_REGISTRATION=true \
|
||||
-v /tmp/homebox-data:/data \
|
||||
homebox:test
|
||||
|
||||
# Wait for startup
|
||||
sleep 10
|
||||
|
||||
# Run verification tests
|
||||
cd frontend
|
||||
TEST_DATA_FILE=/tmp/test-users.json \
|
||||
E2E_BASE_URL=http://localhost:7745 \
|
||||
pnpm exec playwright test \
|
||||
--project=chromium \
|
||||
test/upgrade/upgrade-verification.spec.ts
|
||||
|
||||
# Cleanup
|
||||
docker stop homebox-test
|
||||
docker rm homebox-test
|
||||
```
|
||||
|
||||
## Artifacts
|
||||
|
||||
The workflow produces several artifacts:
|
||||
|
||||
1. **playwright-report-upgrade-test**: HTML report of test results
|
||||
2. **playwright-traces**: Detailed traces for debugging failures
|
||||
3. **Docker logs**: Collected on failure for troubleshooting
|
||||
|
||||
## Failure Scenarios
|
||||
|
||||
The workflow will fail if:
|
||||
- The stable version fails to start
|
||||
- Test data creation fails
|
||||
- The new version fails to start with existing data
|
||||
- Any verification test fails
|
||||
- Database migrations fail
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Test Data Creation Fails
|
||||
|
||||
Check the Docker logs:
|
||||
```bash
|
||||
docker logs homebox-old
|
||||
```
|
||||
|
||||
Verify the API is accessible:
|
||||
```bash
|
||||
curl http://localhost:7745/api/v1/status
|
||||
```
|
||||
|
||||
### Verification Tests Fail
|
||||
|
||||
1. Download the Playwright report from GitHub Actions artifacts
|
||||
2. Review the HTML report for detailed failure information
|
||||
3. Check traces for visual debugging
|
||||
|
||||
### Database Issues
|
||||
|
||||
If migrations fail:
|
||||
```bash
|
||||
# Check database file
|
||||
ls -lh /tmp/homebox-data-new/homebox.db
|
||||
|
||||
# Check Docker logs for migration errors
|
||||
docker logs homebox-new
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- [ ] Test multiple upgrade paths (e.g., v0.10 → v0.11 → v0.12)
|
||||
- [ ] Test with PostgreSQL backend in addition to SQLite
|
||||
- [ ] Add performance benchmarks
|
||||
- [ ] Test with larger datasets
|
||||
- [ ] Add API-level verification in addition to UI tests
|
||||
- [ ] Test backup and restore functionality
|
||||
|
||||
## Related Files
|
||||
|
||||
- `.github/workflows/upgrade-test.yaml` - Main workflow definition
|
||||
- `.github/scripts/upgrade-test/create-test-data.sh` - Data generation script
|
||||
- `frontend/test/upgrade/upgrade-verification.spec.ts` - Playwright verification tests
|
||||
- `.github/workflows/e2e-partial.yaml` - Standard E2E test workflow (for reference)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions about this workflow:
|
||||
1. Check the GitHub Actions run logs
|
||||
2. Review this documentation
|
||||
3. Open an issue in the repository
|
||||
153
.github/scripts/upgrade-test/create-test-data.sh
vendored
153
.github/scripts/upgrade-test/create-test-data.sh
vendored
@@ -1,153 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to create test data in HomeBox for upgrade testing
|
||||
|
||||
set -e
|
||||
|
||||
HOMEBOX_URL="${HOMEBOX_URL:-http://localhost:7745}"
|
||||
API_URL="${HOMEBOX_URL}/api/v1"
|
||||
TEST_DATA_FILE="${TEST_DATA_FILE:-/tmp/test-users.json}"
|
||||
|
||||
echo "Creating test data in HomeBox at $HOMEBOX_URL"
|
||||
|
||||
# Function to make API calls with error handling
|
||||
api_call() {
|
||||
local method=$1
|
||||
local endpoint=$2
|
||||
local data=$3
|
||||
local token=$4
|
||||
|
||||
local response
|
||||
if [ -n "$token" ]; then
|
||||
response=$(curl -s -X "$method" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"$API_URL$endpoint")
|
||||
else
|
||||
response=$(curl -s -X "$method" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"$API_URL$endpoint")
|
||||
fi
|
||||
|
||||
# Validate response is proper JSON
|
||||
if ! echo "$response" | jq '.' > /dev/null 2>&1; then
|
||||
echo "Invalid API response for $endpoint: $response" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
# Function to initialize the test data JSON file
|
||||
initialize_test_data() {
|
||||
echo "Initializing test data JSON file: $TEST_DATA_FILE"
|
||||
if [ -f "$TEST_DATA_FILE" ]; then
|
||||
echo "Removing existing test data file..."
|
||||
rm -f "$TEST_DATA_FILE"
|
||||
fi
|
||||
echo "{\"users\":[],\"locations\":[],\"labels\":[],\"items\":[],\"attachments\":[],\"notifiers\":[]}" > "$TEST_DATA_FILE"
|
||||
}
|
||||
|
||||
# Function to add content to JSON data file
|
||||
add_to_test_data() {
|
||||
local key=$1
|
||||
local value=$2
|
||||
|
||||
jq --argjson data "$value" ".${key} += [\$data]" "$TEST_DATA_FILE" > "${TEST_DATA_FILE}.tmp" && mv "${TEST_DATA_FILE}.tmp" "$TEST_DATA_FILE"
|
||||
}
|
||||
|
||||
# Register a user and get their auth token
|
||||
register_user() {
|
||||
local email=$1
|
||||
local name=$2
|
||||
local password=$3
|
||||
local group_token=$4
|
||||
|
||||
echo "Registering user: $email"
|
||||
local payload="{\"email\":\"$email\",\"name\":\"$name\",\"password\":\"$password\""
|
||||
if [ -n "$group_token" ]; then
|
||||
payload="$payload,\"groupToken\":\"$group_token\""
|
||||
fi
|
||||
payload="$payload}"
|
||||
|
||||
api_call "POST" "/users/register" "$payload"
|
||||
}
|
||||
|
||||
# Main logic for creating test data
|
||||
initialize_test_data
|
||||
|
||||
# Group 1: Create 5 users
|
||||
echo "=== Creating Group 1 Users ==="
|
||||
group1_user1_response=$(register_user "user1@homebox.test" "User One" "password123")
|
||||
group1_user1_token=$(echo "$group1_user1_response" | jq -r '.token // empty')
|
||||
group1_invite_token=$(echo "$group1_user1_response" | jq -r '.group.inviteToken // empty')
|
||||
|
||||
if [ -z "$group1_user1_token" ]; then
|
||||
echo "Failed to register the first group user" >&2
|
||||
exit 1
|
||||
fi
|
||||
add_to_test_data "users" "{\"email\": \"user1@homebox.test\", \"token\": \"$group1_user1_token\", \"group\": 1}"
|
||||
|
||||
# Add 4 more users to the same group
|
||||
for user in 2 3 4 5; do
|
||||
response=$(register_user "user$user@homebox.test" "User $user" "password123" "$group1_invite_token")
|
||||
token=$(echo "$response" | jq -r '.token // empty')
|
||||
add_to_test_data "users" "{\"email\": \"user$user@homebox.test\", \"token\": \"$token\", \"group\": 1}"
|
||||
done
|
||||
|
||||
# Group 2: Create 2 users
|
||||
echo "=== Creating Group 2 Users ==="
|
||||
group2_user1_response=$(register_user "user6@homebox.test" "User Six" "password123")
|
||||
group2_user1_token=$(echo "$group2_user1_response" | jq -r '.token // empty')
|
||||
group2_invite_token=$(echo "$group2_user1_response" | jq -r '.group.inviteToken // empty')
|
||||
add_to_test_data "users" "{\"email\": \"user6@homebox.test\", \"token\": \"$group2_user1_token\", \"group\": 2}"
|
||||
|
||||
response=$(register_user "user7@homebox.test" "User Seven" "password123" "$group2_invite_token")
|
||||
group2_user2_token=$(echo "$response" | jq -r '.token // empty')
|
||||
add_to_test_data "users" "{\"email\": \"user7@homebox.test\", \"token\": \"$group2_user2_token\", \"group\": 2}"
|
||||
|
||||
# Create Locations
|
||||
echo "=== Creating Locations ==="
|
||||
group1_locations=()
|
||||
group1_locations+=("$(api_call "POST" "/locations" "{ \"name\": \"Living Room\", \"description\": \"Family area\" }" "$group1_user1_token")")
|
||||
group1_locations+=("$(api_call "POST" "/locations" "{ \"name\": \"Garage\", \"description\": \"Storage area\" }" "$group1_user1_token")")
|
||||
group2_locations=()
|
||||
group2_locations+=("$(api_call "POST" "/locations" "{ \"name\": \"Office\", \"description\": \"Workspace\" }" "$group2_user1_token")")
|
||||
|
||||
# Add Locations to Test Data
|
||||
for loc in "${group1_locations[@]}"; do
|
||||
loc_id=$(echo "$loc" | jq -r '.id // empty')
|
||||
add_to_test_data "locations" "{\"id\": \"$loc_id\", \"group\": 1}"
|
||||
done
|
||||
|
||||
for loc in "${group2_locations[@]}"; do
|
||||
loc_id=$(echo "$loc" | jq -r '.id // empty')
|
||||
add_to_test_data "locations" "{\"id\": \"$loc_id\", \"group\": 2}"
|
||||
done
|
||||
|
||||
# Create Labels
|
||||
echo "=== Creating Labels ==="
|
||||
label1=$(api_call "POST" "/labels" "{ \"name\": \"Electronics\", \"description\": \"Devices\" }" "$group1_user1_token")
|
||||
add_to_test_data "labels" "$label1"
|
||||
|
||||
label2=$(api_call "POST" "/labels" "{ \"name\": \"Important\", \"description\": \"High Priority\" }" "$group1_user1_token")
|
||||
add_to_test_data "labels" "$label2"
|
||||
|
||||
# Create Items and Attachments
|
||||
echo "=== Creating Items and Attachments ==="
|
||||
item1=$(api_call "POST" "/items" "{ \"name\": \"Laptop\", \"description\": \"Work laptop\", \"locationId\": \"$(echo ${group1_locations[0]} | jq -r '.id // empty')\" }" "$group1_user1_token")
|
||||
item1_id=$(echo "$item1" | jq -r '.id // empty')
|
||||
add_to_test_data "items" "{\"id\": \"$item1_id\", \"group\": 1}"
|
||||
|
||||
attachment1=$(api_call "POST" "/items/$item1_id/attachments" "" "$group1_user1_token")
|
||||
add_to_test_data "attachments" "{\"id\": \"$(echo $attachment1 | jq -r '.id // empty')\", \"itemId\": \"$item1_id\"}"
|
||||
|
||||
# Create Test Notifier
|
||||
echo "=== Creating Notifiers ==="
|
||||
notifier=$(api_call "POST" "/notifiers" "{ \"name\": \"TESTING\", \"url\": \"https://example.com/webhook\", \"isActive\": true }" "$group1_user1_token")
|
||||
add_to_test_data "notifiers" "$notifier"
|
||||
|
||||
echo "=== Test Data Creation Complete ==="
|
||||
cat "$TEST_DATA_FILE" | jq
|
||||
184
.github/workflows/upgrade-test.yaml
vendored
184
.github/workflows/upgrade-test.yaml
vendored
@@ -1,184 +0,0 @@
|
||||
name: HomeBox Upgrade Test
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 2 AM UTC
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.github/workflows/upgrade-test.yaml'
|
||||
- '.github/scripts/upgrade-test/**'
|
||||
|
||||
jobs:
|
||||
upgrade-test:
|
||||
name: Test Upgrade Path
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
# Step 1: Checkout repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Step 2: Setup dependencies (Node.js, Docker, pnpm, and Playwright)
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 9.12.2
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
# Step 3: Prepare environment and /tmp directories
|
||||
- name: Create test data directories
|
||||
run: |
|
||||
mkdir -p /tmp/homebox-data-old
|
||||
mkdir -p /tmp/homebox-data-new
|
||||
chmod -R 777 /tmp/homebox-data-old
|
||||
chmod -R 777 /tmp/homebox-data-new
|
||||
echo "Directories created:"
|
||||
ls -la /tmp/
|
||||
|
||||
# Step 4: Pull and start the stable HomeBox image
|
||||
- name: Pull latest stable HomeBox image
|
||||
run: |
|
||||
docker pull ghcr.io/sysadminsmedia/homebox:latest
|
||||
|
||||
- name: Start HomeBox (stable version)
|
||||
run: |
|
||||
docker run -d \
|
||||
--name homebox-old \
|
||||
--restart unless-stopped \
|
||||
-p 7745:7745 \
|
||||
-e HBOX_LOG_LEVEL=debug \
|
||||
-e HBOX_OPTIONS_ALLOW_REGISTRATION=true \
|
||||
-e TZ=UTC \
|
||||
-v /tmp/homebox-data-old:/data \
|
||||
ghcr.io/sysadminsmedia/homebox:latest
|
||||
|
||||
# Wait for the service to be ready
|
||||
timeout 60 bash -c 'until curl -f http://localhost:7745/api/v1/status; do sleep 2; done'
|
||||
echo "HomeBox stable version is ready"
|
||||
|
||||
# Step 5: Run test data script
|
||||
- name: Create test data (users, items, locations, labels)
|
||||
run: |
|
||||
chmod +x .github/scripts/upgrade-test/create-test-data.sh
|
||||
.github/scripts/upgrade-test/create-test-data.sh
|
||||
env:
|
||||
HOMEBOX_URL: http://localhost:7745
|
||||
|
||||
- name: Validate test data creation
|
||||
run: |
|
||||
echo "Verifying test data was created..."
|
||||
# Check test-users.json content
|
||||
cat /tmp/test-users.json | jq || echo "Test-users file is empty or malformed!"
|
||||
# Check the database file exists
|
||||
if [ ! -f /tmp/homebox-data-old/homebox.db ]; then
|
||||
echo "No database found in the old instance directory!" && exit 1
|
||||
fi
|
||||
echo "Test data creation validated successfully!"
|
||||
|
||||
# Step 6: Stop the HomeBox stable instance
|
||||
- name: Stop old HomeBox instance
|
||||
run: |
|
||||
docker stop homebox-old
|
||||
docker rm homebox-old
|
||||
|
||||
# Step 7: Build HomeBox from the main branch
|
||||
- name: Build HomeBox from main branch
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=main \
|
||||
--build-arg COMMIT=${{ github.sha }} \
|
||||
--build-arg BUILD_TIME="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||
-t homebox:test \
|
||||
-f Dockerfile \
|
||||
.
|
||||
|
||||
# Step 8: Start the new HomeBox version with migrated data
|
||||
- name: Copy data to new location
|
||||
run: |
|
||||
cp -r /tmp/homebox-data-old/* /tmp/homebox-data-new/
|
||||
chmod -R 777 /tmp/homebox-data-new
|
||||
|
||||
- name: Start HomeBox (new version)
|
||||
run: |
|
||||
docker run -d \
|
||||
--name homebox-new \
|
||||
--restart unless-stopped \
|
||||
-p 7745:7745 \
|
||||
-e HBOX_LOG_LEVEL=debug \
|
||||
-e HBOX_OPTIONS_ALLOW_REGISTRATION=true \
|
||||
-e TZ=UTC \
|
||||
-v /tmp/homebox-data-new:/data \
|
||||
homebox:test
|
||||
|
||||
# Wait for the updated service to be ready
|
||||
timeout 60 bash -c 'until curl -f http://localhost:7745/api/v1/status; do sleep 2; done'
|
||||
echo "HomeBox new version is ready"
|
||||
|
||||
# Step 9: Execute Playwright verification tests
|
||||
- name: Run Playwright verification tests
|
||||
run: |
|
||||
cd frontend
|
||||
TEST_DATA_FILE=/tmp/test-users.json \
|
||||
E2E_BASE_URL=http://localhost:7745 \
|
||||
pnpm exec playwright test \
|
||||
-c ./test/playwright.config.ts \
|
||||
test/upgrade/upgrade-verification.spec.ts
|
||||
env:
|
||||
HOMEBOX_URL: http://localhost:7745
|
||||
|
||||
# Step 10: Upload reports for review
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-upgrade-test
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload test traces
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-traces
|
||||
path: frontend/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Step 11: Collect logs for failed instances
|
||||
- name: Collect logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Docker logs for new version ==="
|
||||
docker logs homebox-new || true
|
||||
echo "=== Database content ==="
|
||||
ls -la /tmp/homebox-data-new || true
|
||||
|
||||
# Step 12: Cleanup resources
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker stop homebox-new || true
|
||||
docker rm homebox-new || true
|
||||
docker rmi homebox:test || true
|
||||
@@ -108,7 +108,7 @@ func run(cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.ToLower(cfg.Database.Driver) == config.DriverPostgres {
|
||||
if strings.ToLower(cfg.Database.Driver) == "postgres" {
|
||||
if !validatePostgresSSLMode(cfg.Database.SslMode) {
|
||||
log.Error().Str("sslmode", cfg.Database.SslMode).Msg("invalid sslmode")
|
||||
return fmt.Errorf("invalid sslmode: %s", cfg.Database.SslMode)
|
||||
|
||||
@@ -41,7 +41,7 @@ func setupStorageDir(cfg *config.Config) error {
|
||||
func setupDatabaseURL(cfg *config.Config) (string, error) {
|
||||
databaseURL := ""
|
||||
switch strings.ToLower(cfg.Database.Driver) {
|
||||
case config.DriverSqlite3:
|
||||
case "sqlite3":
|
||||
databaseURL = cfg.Database.SqlitePath
|
||||
dbFilePath := strings.Split(cfg.Database.SqlitePath, "?")[0]
|
||||
dbDir := filepath.Dir(dbFilePath)
|
||||
@@ -49,7 +49,7 @@ func setupDatabaseURL(cfg *config.Config) (string, error) {
|
||||
log.Error().Err(err).Str("path", dbDir).Msg("failed to create SQLite database directory")
|
||||
return "", fmt.Errorf("failed to create SQLite database directory: %w", err)
|
||||
}
|
||||
case config.DriverPostgres:
|
||||
case "postgres":
|
||||
databaseURL = fmt.Sprintf("host=%s port=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Database, cfg.Database.SslMode)
|
||||
if cfg.Database.Username != "" {
|
||||
databaseURL += fmt.Sprintf(" user=%s", cfg.Database.Username)
|
||||
|
||||
@@ -10,22 +10,31 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
|
||||
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
|
||||
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
|
||||
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
||||
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
||||
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
||||
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/pubsub v1.50.0 h1:hnYpOIxVlgVD1Z8LN7est4DQZK3K6tvZNurZjIVjUe0=
|
||||
cloud.google.com/go/pubsub v1.50.0/go.mod h1:Di2Y+nqXBpIS+dXUEJPQzLh8PbIQZMLE9IVUFhf2zmM=
|
||||
cloud.google.com/go/pubsub v1.50.1 h1:fzbXpPyJnSGvWXF1jabhQeXyxdbCIkXTpjXHy7xviBM=
|
||||
cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk=
|
||||
cloud.google.com/go/pubsub/v2 v2.2.1 h1:3brZcshL3fIiD1qOxAE2QW9wxsfjioy014x4yC9XuYI=
|
||||
cloud.google.com/go/pubsub/v2 v2.2.1/go.mod h1:O5f0KHG9zDheZAd3z5rlCRhxt2JQtB+t/IYLKK3Bpvw=
|
||||
cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI=
|
||||
cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
|
||||
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
||||
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=
|
||||
entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U=
|
||||
github.com/Azure/azure-amqp-common-go/v3 v3.2.3 h1:uDF62mbd9bypXWi19V1bN5NZEO84JqgmI5G73ibAmrk=
|
||||
@@ -79,6 +88,8 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l
|
||||
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
||||
github.com/ardanlabs/conf/v3 v3.9.0 h1:aRBYHeD39/OkuaEXYIEoi4wvF3OnS7jUAPxXyLfEu20=
|
||||
github.com/ardanlabs/conf/v3 v3.9.0/go.mod h1:XlL9P0quWP4m1weOVFmlezabinbZLI05niDof/+Ochk=
|
||||
github.com/ardanlabs/conf/v3 v3.10.0 h1:qIrJ/WBmH/hFQ/IX4xH9LX9LzwK44T9aEOy78M+4S+0=
|
||||
github.com/ardanlabs/conf/v3 v3.10.0/go.mod h1:XlL9P0quWP4m1weOVFmlezabinbZLI05niDof/+Ochk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk=
|
||||
@@ -172,10 +183,14 @@ github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzP
|
||||
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
|
||||
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gen2brain/avif v0.4.4 h1:Ga/ss7qcWWQm2bxFpnjYjhJsNfZrWs5RsyklgFjKRSE=
|
||||
github.com/gen2brain/avif v0.4.4/go.mod h1:/XCaJcjZraQwKVhpu9aEd9aLOssYOawLvhMBtmHVGqk=
|
||||
github.com/gen2brain/heic v0.4.6 h1:sNh3mfaEZLmDJnFc5WoLxCzh/wj5GwfJScPfvF5CNJE=
|
||||
github.com/gen2brain/heic v0.4.6/go.mod h1:ECnpqbqLu0qSje4KSNWUUDK47UPXPzl80T27GWGEL5I=
|
||||
github.com/gen2brain/heic v0.4.7 h1:xw/e9R3HdIvb+uEhRDMRJdviYnB3ODe/VwL8SYLaMGc=
|
||||
github.com/gen2brain/heic v0.4.7/go.mod h1:ECnpqbqLu0qSje4KSNWUUDK47UPXPzl80T27GWGEL5I=
|
||||
github.com/gen2brain/jpegxl v0.4.5 h1:TWpVEn5xkIfsswzkjHBArd0Cc9AE0tbjBSoa0jDsrbo=
|
||||
@@ -195,10 +210,16 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
|
||||
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
|
||||
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
@@ -228,6 +249,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||
@@ -267,6 +290,8 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
@@ -337,6 +362,8 @@ github.com/nats-io/jwt/v2 v2.5.0 h1:WQQ40AAlqqfx+f6ku+i0pOVm+ASirD4fUh+oQsiE9Ak=
|
||||
github.com/nats-io/jwt/v2 v2.5.0/go.mod h1:24BeQtRwxRV8ruvC4CojXlx/WQ/VjuwlYiH+vu/+ibI=
|
||||
github.com/nats-io/nats-server/v2 v2.9.23 h1:6Wj6H6QpP9FMlpCyWUaNu2yeZ/qGj+mdRkZ1wbikExU=
|
||||
github.com/nats-io/nats-server/v2 v2.9.23/go.mod h1:wEjrEy9vnqIGE4Pqz4/c75v9Pmaq7My2IgFmnykc4C0=
|
||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||
@@ -353,6 +380,8 @@ github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU=
|
||||
github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
@@ -408,6 +437,8 @@ github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSy
|
||||
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
|
||||
github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
|
||||
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
@@ -445,16 +476,26 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iLZEPtdV7vie80Yyqf7oikJLjQ/myi0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -475,13 +516,21 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -491,12 +540,18 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -512,6 +567,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -521,6 +578,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
@@ -528,6 +587,8 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -536,16 +597,28 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA=
|
||||
google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4=
|
||||
google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
|
||||
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs=
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s=
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -567,6 +640,8 @@ modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/libc v1.67.2 h1:ZbNmly1rcbjhot5jlOZG0q4p5VwFfjwWqZ5rY2xxOXo=
|
||||
modernc.org/libc v1.67.2/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
@@ -577,6 +652,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/sqlite v1.41.0 h1:bJXddp4ZpsqMsNN1vS0jWo4IJTZzb8nWpcgvyCFG9Ck=
|
||||
modernc.org/sqlite v1.41.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
|
||||
5
backend/internal/data/ent/item_predicates.go
generated
5
backend/internal/data/ent/item_predicates.go
generated
@@ -4,7 +4,6 @@ import (
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/item"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/predicate"
|
||||
conf "github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
|
||||
)
|
||||
|
||||
@@ -25,7 +24,7 @@ func AccentInsensitiveContains(field string, searchValue string) predicate.Item
|
||||
dialect := s.Dialect()
|
||||
|
||||
switch dialect {
|
||||
case conf.DriverSqlite3:
|
||||
case "sqlite3":
|
||||
// For SQLite, we'll create a custom normalization function using REPLACE
|
||||
// to handle common accented characters
|
||||
normalizeFunc := buildSQLiteNormalizeExpression(s.C(field))
|
||||
@@ -33,7 +32,7 @@ func AccentInsensitiveContains(field string, searchValue string) predicate.Item
|
||||
"LOWER("+normalizeFunc+") LIKE ?",
|
||||
"%"+normalizedSearch+"%",
|
||||
))
|
||||
case conf.DriverPostgres:
|
||||
case "postgres":
|
||||
// For PostgreSQL, use REPLACE-based normalization to avoid unaccent dependency
|
||||
normalizeFunc := buildGenericNormalizeExpression(s.C(field))
|
||||
// Use sql.P() for proper PostgreSQL parameter binding ($1, $2, etc.)
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
)
|
||||
|
||||
//go:embed all:postgres
|
||||
@@ -22,9 +21,9 @@ var sqliteFiles embed.FS
|
||||
// embedded file system containing the migration files for the specified dialect.
|
||||
func Migrations(dialect string) (embed.FS, error) {
|
||||
switch dialect {
|
||||
case config.DriverPostgres:
|
||||
case "postgres":
|
||||
return postgresFiles, nil
|
||||
case config.DriverSqlite3:
|
||||
case "sqlite3":
|
||||
return sqliteFiles, nil
|
||||
default:
|
||||
log.Error().Str("dialect", dialect).Msg("unknown sql dialect")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
DriverSqlite3 = "sqlite3"
|
||||
DriverPostgres = "postgres"
|
||||
DriverSqlite3 = "sqlite3"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
|
||||
@@ -43,7 +43,6 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: 'API Docs', link: '/en/api' },
|
||||
{ text: 'Demo', link: 'https://demo.homebox.software' },
|
||||
{ text: 'Blog', link: 'https://sysadminsjournal.com/tag/homebox/' }
|
||||
],
|
||||
|
||||
sidebar: {
|
||||
|
||||
173
frontend/components/Admin/CreateInviteModal.vue
Normal file
173
frontend/components/Admin/CreateInviteModal.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<BaseModal :dialog-id="DialogID.CreateInvite" title="Create Invite">
|
||||
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="createInvite()">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="invite-role">Role</Label>
|
||||
<Select :model-value="form.role" @update:model-value="v => (form.role = String(v))">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">owner</SelectItem>
|
||||
<SelectItem value="admin">admin</SelectItem>
|
||||
<SelectItem value="editor">editor</SelectItem>
|
||||
<SelectItem value="viewer">viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="invite-expires">Expires</Label>
|
||||
<div :class="form.no_expiry ? 'opacity-50 pointer-events-none' : ''">
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full justify-start text-left font-normal"
|
||||
:class="!form.expires_at && 'text-muted-foreground'"
|
||||
>
|
||||
<CalendarIcon class="mr-2 size-4" />
|
||||
{{ formattedExpires ? formattedExpires : "Pick a date" }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar :model-value="localExpires as any" @update:model-value="val => (localExpires = val)" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="invite-max-uses">Max Uses</Label>
|
||||
<Input
|
||||
id="invite-max-uses"
|
||||
v-model.number="form.max_uses"
|
||||
type="number"
|
||||
min="1"
|
||||
:disabled="form.unlimited_uses"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="no-expiry" v-model="form.no_expiry" />
|
||||
<Label for="no-expiry" class="select-none">No expiry</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="unlimited-uses" v-model="form.unlimited_uses" />
|
||||
<Label for="unlimited-uses" class="select-none">Unlimited uses</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-row-reverse gap-2">
|
||||
<Button type="submit">Generate Invite</Button>
|
||||
<Button variant="outline" type="button" @click="cancel">Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, ref, watch } from "vue";
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import { useDialogHotkey, useDialog } from "~/components/ui/dialog-provider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "~/components/ui/popover";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { Calendar as CalendarIcon } from "lucide-vue-next";
|
||||
import { format } from "date-fns";
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "~/components/ui/select";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { api, type Invite } from "~/mock/collections";
|
||||
import { toast } from "~/components/ui/sonner";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
|
||||
useDialogHotkey(DialogID.CreateInvite, { code: "Digit9" });
|
||||
|
||||
const { closeDialog } = useDialog();
|
||||
|
||||
const form = reactive({
|
||||
role: "viewer",
|
||||
expires_at: undefined as unknown,
|
||||
max_uses: 1,
|
||||
no_expiry: false,
|
||||
unlimited_uses: false,
|
||||
});
|
||||
|
||||
// local date ref to satisfy Calendar's expected Date type
|
||||
const localExpires = ref<Date | undefined>(form.expires_at as Date | undefined);
|
||||
|
||||
watch(
|
||||
() => form.expires_at,
|
||||
v => {
|
||||
localExpires.value = (v as Date) || undefined;
|
||||
}
|
||||
);
|
||||
|
||||
watch(localExpires, v => {
|
||||
form.expires_at = v as unknown;
|
||||
});
|
||||
|
||||
const formattedExpires = computed(() => {
|
||||
const v = form.expires_at as Date | string | undefined | null;
|
||||
if (!v) return null;
|
||||
if (v instanceof Date) return format(v, "PPP");
|
||||
try {
|
||||
const d = new Date(String(v));
|
||||
if (!isNaN(d.getTime())) return format(d, "PPP");
|
||||
} catch (e) {
|
||||
// fallthrough
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
|
||||
function reset() {
|
||||
form.role = "viewer";
|
||||
form.expires_at = undefined;
|
||||
form.max_uses = 1;
|
||||
form.no_expiry = false;
|
||||
form.unlimited_uses = false;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
reset();
|
||||
closeDialog(DialogID.CreateInvite);
|
||||
}
|
||||
|
||||
function generateCode(length = 6) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i++) out += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
return out;
|
||||
}
|
||||
|
||||
function createInvite() {
|
||||
const collectionId = api.getCollections()[0]?.id ?? "";
|
||||
const invite: Partial<Invite> = {
|
||||
id: generateCode(6),
|
||||
collectionId,
|
||||
role: form.role as Invite["role"],
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: form.no_expiry
|
||||
? undefined
|
||||
: form.expires_at
|
||||
? form.expires_at instanceof Date
|
||||
? form.expires_at.toISOString()
|
||||
: String(form.expires_at)
|
||||
: undefined,
|
||||
max_uses: form.unlimited_uses ? undefined : form.max_uses || undefined,
|
||||
uses: 0,
|
||||
};
|
||||
|
||||
api.addInvite(invite);
|
||||
toast.success("Invite created");
|
||||
reset();
|
||||
closeDialog(DialogID.CreateInvite, true);
|
||||
}
|
||||
</script>
|
||||
287
frontend/components/Admin/UserFormDialog.vue
Normal file
287
frontend/components/Admin/UserFormDialog.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, onUnmounted } from "vue";
|
||||
import type { User as MockUser, Collection as MockCollection } from "~/mock/collections";
|
||||
import { api } from "~/mock/collections";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
|
||||
// dialog provider
|
||||
const { closeDialog, registerOpenDialogCallback } = useDialog();
|
||||
|
||||
// local collections snapshot used for checkbox list
|
||||
const availableCollections = ref<MockCollection[]>(api.getCollections() as MockCollection[]);
|
||||
const isNew = ref(true);
|
||||
|
||||
const localEditing = reactive<MockUser>({
|
||||
id: String(Date.now()),
|
||||
name: "",
|
||||
email: "",
|
||||
role: "user",
|
||||
password_set: false,
|
||||
collections: [],
|
||||
});
|
||||
|
||||
const localCollectionIds = ref<string[]>([]);
|
||||
const localNewPassword = ref("");
|
||||
const newAddCollectionId = ref<string>("");
|
||||
|
||||
onMounted(() => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.EditUser, params => {
|
||||
// refresh available collections each time
|
||||
availableCollections.value = api.getCollections() as MockCollection[];
|
||||
|
||||
if (params && (params as { userId?: string }).userId) {
|
||||
const u = api.getUser(params.userId!);
|
||||
if (u) {
|
||||
Object.assign(localEditing, u as MockUser);
|
||||
localCollectionIds.value = (u.collections ?? []).map(c => c.id);
|
||||
isNew.value = false;
|
||||
} else {
|
||||
reset();
|
||||
isNew.value = true;
|
||||
}
|
||||
} else {
|
||||
// new user
|
||||
reset();
|
||||
isNew.value = true;
|
||||
}
|
||||
localNewPassword.value = "";
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
type Membership = { id: string; role: "owner" | "admin" | "editor" | "viewer" };
|
||||
|
||||
function getCollectionName(id: string) {
|
||||
const found = availableCollections.value.find(c => c.id === id);
|
||||
return found ? found.name : id;
|
||||
}
|
||||
|
||||
// localEditing will be set when dialog opens via registerOpenDialogCallback
|
||||
|
||||
function close() {
|
||||
reset();
|
||||
closeDialog(DialogID.EditUser);
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
if (!localEditing.name.trim() || !localEditing.email.trim()) {
|
||||
alert("Name and email are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (localNewPassword.value && localEditing) localEditing.password_set = true;
|
||||
|
||||
const existing = api.getUser(localEditing.id);
|
||||
if (existing) {
|
||||
const updated = {
|
||||
...existing,
|
||||
name: localEditing.name,
|
||||
email: localEditing.email,
|
||||
role: localEditing.role,
|
||||
password_set: localEditing.password_set,
|
||||
} as MockUser;
|
||||
api.updateUser(updated);
|
||||
} else {
|
||||
const toCreate = { ...localEditing, collections: [] } as MockUser;
|
||||
const created = api.addUser(toCreate);
|
||||
localCollectionIds.value.forEach(id => api.addUserToCollection(created.id, id, "viewer"));
|
||||
}
|
||||
|
||||
// close and signal caller to refresh
|
||||
closeDialog(DialogID.EditUser, true);
|
||||
reset();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
localEditing.id = String(Date.now());
|
||||
localEditing.name = "";
|
||||
localEditing.email = "";
|
||||
localEditing.role = "user";
|
||||
localEditing.password_set = false;
|
||||
localEditing.collections = [];
|
||||
localCollectionIds.value = [];
|
||||
localNewPassword.value = "";
|
||||
}
|
||||
|
||||
function removeMembership(id: string) {
|
||||
const existing = (localEditing.collections ?? []) as Membership[];
|
||||
const found = existing.find((x: Membership) => x.id === id);
|
||||
if (found?.role === "owner") {
|
||||
const ok = confirm(
|
||||
`This user is the owner of this collection.\nRemoving the owner will delete the collection. Continue?`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const existsInApi = !!api.getUser(localEditing.id);
|
||||
if (existsInApi) {
|
||||
const ok = api.removeUserFromCollection(localEditing.id, id);
|
||||
if (ok) {
|
||||
localCollectionIds.value = localCollectionIds.value.filter(x => x !== id);
|
||||
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
|
||||
const refreshed = api.getUser(localEditing.id);
|
||||
if (refreshed) Object.assign(localEditing, refreshed as MockUser);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// not in API yet — local only
|
||||
localCollectionIds.value = localCollectionIds.value.filter(x => x !== id);
|
||||
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
|
||||
}
|
||||
|
||||
function addMembership(id: string) {
|
||||
if (!id) return;
|
||||
const existsInApi = !!api.getUser(localEditing.id);
|
||||
if (existsInApi) {
|
||||
const mem = api.addUserToCollection(localEditing.id, id, "viewer");
|
||||
if (mem) {
|
||||
if (!localCollectionIds.value.includes(id)) localCollectionIds.value.push(id);
|
||||
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
|
||||
localEditing.collections.push(mem as Membership);
|
||||
const refreshed = api.getUser(localEditing.id);
|
||||
if (refreshed) Object.assign(localEditing, refreshed as MockUser);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// new user — add locally
|
||||
if (!localCollectionIds.value.includes(id)) localCollectionIds.value.push(id);
|
||||
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
|
||||
localEditing.collections.push({ id, role: "viewer" });
|
||||
}
|
||||
|
||||
function updateMembershipRole(id: string, role: Membership["role"]) {
|
||||
const existsInApi = !!api.getUser(localEditing.id);
|
||||
if (existsInApi) {
|
||||
// best-effort: remove then re-add with new role if API doesn't expose direct update
|
||||
api.removeUserFromCollection(localEditing.id, id);
|
||||
const mem = api.addUserToCollection(localEditing.id, id, role);
|
||||
if (mem) {
|
||||
const refreshed = api.getUser(localEditing.id);
|
||||
if (refreshed) Object.assign(localEditing, refreshed as MockUser);
|
||||
localCollectionIds.value = (localEditing.collections ?? []).map((c: Membership) => c.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// local-only
|
||||
const existing = (localEditing.collections ?? []) as Membership[];
|
||||
const found = existing.find(x => x.id === id);
|
||||
if (found) found.role = role;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :dialog-id="DialogID.EditUser">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isNew ? "Add User" : "Edit User" }}</DialogTitle>
|
||||
<DialogDescription>Manage user details and collection memberships.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="onSave">
|
||||
<label class="block">
|
||||
<div class="mb-1 text-sm">Name</div>
|
||||
<Input v-model="localEditing.name" />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<div class="mb-1 text-sm">Email</div>
|
||||
<Input v-model="localEditing.email" />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<div class="mb-1 text-sm">Password</div>
|
||||
<Input v-model="localNewPassword" type="password" placeholder="Leave blank to keep" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-sm">Collections</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="m in localEditing.collections ?? []"
|
||||
:key="m.id"
|
||||
class="flex items-center justify-between rounded-lg border py-1 pl-3 pr-1"
|
||||
>
|
||||
<div class="text-lg font-medium">
|
||||
<Badge>
|
||||
{{ getCollectionName(m.id) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Select v-model="m.role" @update:model-value="val => updateMembershipRole(m.id, val)">
|
||||
<SelectTrigger class="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">Owner</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="editor">Editor</SelectItem>
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="ml-2"
|
||||
:title="$t ? $t('global.remove') : 'Remove'"
|
||||
@click.prevent="removeMembership(m.id)"
|
||||
>
|
||||
<MdiClose class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<Select v-model="newAddCollectionId">
|
||||
<SelectTrigger class="flex-1">
|
||||
<SelectValue placeholder="Select collection to add" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="c in availableCollections.filter(c => !localCollectionIds.includes(c.id))"
|
||||
:key="c.id"
|
||||
:value="c.id"
|
||||
>
|
||||
{{ c.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
class="ml-2 w-10 px-0"
|
||||
variant="default"
|
||||
size="lg"
|
||||
:disabled="!newAddCollectionId"
|
||||
@click="addMembership(newAddCollectionId)"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" type="button" @click="close">Cancel</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
142
frontend/components/App/CollectionSelector.vue
Normal file
142
frontend/components/App/CollectionSelector.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:size="sidebar.state.value === 'collapsed' ? 'icon' : undefined"
|
||||
:class="sidebar.state.value === 'collapsed' ? 'size-10' : 'w-full justify-between'"
|
||||
aria-label="Collections"
|
||||
title="Collections"
|
||||
>
|
||||
<template v-if="sidebar.state.value === 'collapsed'">
|
||||
<MdiHomeGroup class="size-5" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="flex items-center truncate">
|
||||
<span class="truncate">
|
||||
{{ selectedCollection && selectedCollection.name ? selectedCollection.name : "Select collection" }}
|
||||
</span>
|
||||
<span v-if="selectedCollection?.role" class="ml-2">
|
||||
<Badge class="whitespace-nowrap" :variant="roleVariant(selectedCollection?.role)">
|
||||
{{ selectedCollection?.role }}
|
||||
</Badge>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</template>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
:class="[sidebar.state.value === 'collapsed' ? 'min-w-48 p-0' : 'w-[--reka-popper-anchor-width] p-0']"
|
||||
>
|
||||
<Command :ignore-filter="true">
|
||||
<CommandGroup>
|
||||
<CommandItem as-child value="collection-settings">
|
||||
<NuxtLink to="/collection" class="flex w-full items-center">
|
||||
<Settings class="mr-2 size-4" />
|
||||
Collection Settings
|
||||
</NuxtLink>
|
||||
</CommandItem>
|
||||
<CommandItem value="create-collection" @select="() => {}">
|
||||
<Plus class="mr-2 size-4" /> Create New Collection
|
||||
</CommandItem>
|
||||
<CommandItem value="join-collection" @select="() => {}">
|
||||
<Plus class="mr-2 size-4" /> Join Existing Collection
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandInput v-model="search" placeholder="Search collections..." :display-value="_ => ''" />
|
||||
<CommandEmpty>No inventory found</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup heading="Your Collections">
|
||||
<CommandItem
|
||||
v-for="collection in filteredCollections"
|
||||
:key="collection.id"
|
||||
:value="collection.id"
|
||||
@select="selectCollection(collection)"
|
||||
>
|
||||
<Check :class="cn('mr-2 h-4 w-4', value === collection.id ? 'opacity-100' : 'opacity-0')" />
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
{{ collection.name }}
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge class="whitespace-nowrap" variant="outline">{{ collection.count }}</Badge>
|
||||
<Badge class="whitespace-nowrap" :variant="roleVariant(collection.role)">{{ collection.role }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronsUpDown, Plus, Settings } from "lucide-vue-next";
|
||||
import MdiHomeGroup from "~icons/mdi/home-group";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { api } from "~/mock/collections";
|
||||
import type { Collection as MockCollection, User as MockUser } from "~/mock/collections";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { useSidebar } from "@/components/ui/sidebar/utils";
|
||||
|
||||
// api.getCollections returns collection objects augmented with `count` and `role` for the current user
|
||||
type CollectionSummary = MockCollection & { count: number; role: MockUser["collections"][number]["role"] };
|
||||
|
||||
type Props = {
|
||||
modelValue?: string | null;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const open = ref(false);
|
||||
const search = ref("");
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
|
||||
// Use shared mock collections data via fake api (for current user)
|
||||
const collectionsList = ref<CollectionSummary[]>(api.getCollections() as CollectionSummary[]);
|
||||
|
||||
function roleVariant(role: string | undefined) {
|
||||
if (role === "owner") return "default";
|
||||
if (role === "admin") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function selectCollection(collection: CollectionSummary) {
|
||||
if (value.value !== collection.id) {
|
||||
value.value = collection.id;
|
||||
console.log(collection);
|
||||
}
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const selectedCollection = computed(() => {
|
||||
return collectionsList.value.find(o => o.id === value.value) ?? null;
|
||||
});
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
const filteredCollections = computed(() => {
|
||||
const filtered = fuzzysort.go(search.value, collectionsList.value, { key: "name", all: true }).map(i => i.obj);
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Reset search when value is cleared
|
||||
watch(
|
||||
() => value.value,
|
||||
() => {
|
||||
if (!value.value) {
|
||||
search.value = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -1,18 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { Primitive, type PrimitiveProps } from "reka-ui";
|
||||
import { type ButtonVariants, buttonVariants } from ".";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { Primitive, type PrimitiveProps } from "reka-ui";
|
||||
import { type ButtonVariants, buttonVariants } from ".";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"];
|
||||
size?: ButtonVariants["size"];
|
||||
class?: HTMLAttributes["class"];
|
||||
}
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"];
|
||||
size?: ButtonVariants["size"];
|
||||
class?: HTMLAttributes["class"];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
});
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
57
frontend/components/ui/calendar/Calendar.vue
Normal file
57
frontend/components/ui/calendar/Calendar.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarRoot, type CalendarRootEmits, type CalendarRootProps, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from '.'
|
||||
|
||||
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const emits = defineEmits<CalendarRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarRoot
|
||||
v-slot="{ grid, weekDays }"
|
||||
:class="cn('p-3', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<CalendarHeader>
|
||||
<CalendarPrevButton />
|
||||
<CalendarHeading />
|
||||
<CalendarNextButton />
|
||||
</CalendarHeader>
|
||||
|
||||
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||
<CalendarGridHead>
|
||||
<CalendarGridRow>
|
||||
<CalendarHeadCell
|
||||
v-for="day in weekDays" :key="day"
|
||||
>
|
||||
{{ day }}
|
||||
</CalendarHeadCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridHead>
|
||||
<CalendarGridBody>
|
||||
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
|
||||
<CalendarCell
|
||||
v-for="weekDate in weekDates"
|
||||
:key="weekDate.toString()"
|
||||
:date="weekDate"
|
||||
>
|
||||
<CalendarCellTrigger
|
||||
:day="weekDate"
|
||||
:month="month.value"
|
||||
/>
|
||||
</CalendarCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
</div>
|
||||
</CalendarRoot>
|
||||
</template>
|
||||
21
frontend/components/ui/calendar/CalendarCell.vue
Normal file
21
frontend/components/ui/calendar/CalendarCell.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarCell, type CalendarCellProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCell
|
||||
:class="cn('relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCell>
|
||||
</template>
|
||||
35
frontend/components/ui/calendar/CalendarCellTrigger.vue
Normal file
35
frontend/components/ui/calendar/CalendarCellTrigger.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCellTrigger
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal',
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selected
|
||||
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
|
||||
// Disabled
|
||||
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||
// Outside months
|
||||
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCellTrigger>
|
||||
</template>
|
||||
21
frontend/components/ui/calendar/CalendarGrid.vue
Normal file
21
frontend/components/ui/calendar/CalendarGrid.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarGrid, type CalendarGridProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGrid
|
||||
:class="cn('w-full border-collapse space-y-1', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarGrid>
|
||||
</template>
|
||||
11
frontend/components/ui/calendar/CalendarGridBody.vue
Normal file
11
frontend/components/ui/calendar/CalendarGridBody.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarGridBody, type CalendarGridBodyProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<CalendarGridBodyProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridBody v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridBody>
|
||||
</template>
|
||||
11
frontend/components/ui/calendar/CalendarGridHead.vue
Normal file
11
frontend/components/ui/calendar/CalendarGridHead.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarGridHead, type CalendarGridHeadProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<CalendarGridHeadProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridHead v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridHead>
|
||||
</template>
|
||||
18
frontend/components/ui/calendar/CalendarGridRow.vue
Normal file
18
frontend/components/ui/calendar/CalendarGridRow.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarGridRow, type CalendarGridRowProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarGridRow>
|
||||
</template>
|
||||
18
frontend/components/ui/calendar/CalendarHeadCell.vue
Normal file
18
frontend/components/ui/calendar/CalendarHeadCell.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarHeadCell, type CalendarHeadCellProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeadCell :class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarHeadCell>
|
||||
</template>
|
||||
18
frontend/components/ui/calendar/CalendarHeader.vue
Normal file
18
frontend/components/ui/calendar/CalendarHeader.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarHeader, type CalendarHeaderProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarHeader>
|
||||
</template>
|
||||
28
frontend/components/ui/calendar/CalendarHeading.vue
Normal file
28
frontend/components/ui/calendar/CalendarHeading.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarHeading, type CalendarHeadingProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
defineSlots<{
|
||||
default: (props: { headingValue: string }) => any
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeading
|
||||
v-slot="{ headingValue }"
|
||||
:class="cn('text-sm font-medium', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot :heading-value>
|
||||
{{ headingValue }}
|
||||
</slot>
|
||||
</CalendarHeading>
|
||||
</template>
|
||||
29
frontend/components/ui/calendar/CalendarNextButton.vue
Normal file
29
frontend/components/ui/calendar/CalendarNextButton.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarNext
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarNext>
|
||||
</template>
|
||||
29
frontend/components/ui/calendar/CalendarPrevButton.vue
Normal file
29
frontend/components/ui/calendar/CalendarPrevButton.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarPrev
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarPrev>
|
||||
</template>
|
||||
12
frontend/components/ui/calendar/index.ts
Normal file
12
frontend/components/ui/calendar/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as Calendar } from './Calendar.vue'
|
||||
export { default as CalendarCell } from './CalendarCell.vue'
|
||||
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue'
|
||||
export { default as CalendarGrid } from './CalendarGrid.vue'
|
||||
export { default as CalendarGridBody } from './CalendarGridBody.vue'
|
||||
export { default as CalendarGridHead } from './CalendarGridHead.vue'
|
||||
export { default as CalendarGridRow } from './CalendarGridRow.vue'
|
||||
export { default as CalendarHeadCell } from './CalendarHeadCell.vue'
|
||||
export { default as CalendarHeader } from './CalendarHeader.vue'
|
||||
export { default as CalendarHeading } from './CalendarHeading.vue'
|
||||
export { default as CalendarNextButton } from './CalendarNextButton.vue'
|
||||
export { default as CalendarPrevButton } from './CalendarPrevButton.vue'
|
||||
@@ -1,7 +1,12 @@
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
import { createContext } from "reka-ui";
|
||||
import { useMagicKeys, useActiveElement } from "@vueuse/core";
|
||||
import type { BarcodeProduct, ItemSummary, MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import type {
|
||||
BarcodeProduct,
|
||||
ItemSummary,
|
||||
MaintenanceEntry,
|
||||
MaintenanceEntryWithDetails,
|
||||
} from "~~/lib/api/types/data-contracts";
|
||||
|
||||
export enum DialogID {
|
||||
AttachmentEdit = "attachment-edit",
|
||||
@@ -24,6 +29,8 @@ export enum DialogID {
|
||||
PageQRCode = "page-qr-code",
|
||||
UpdateLabel = "update-label",
|
||||
UpdateLocation = "update-location",
|
||||
CreateInvite = "create-invite",
|
||||
EditUser = "edit-user",
|
||||
UpdateTemplate = "update-template",
|
||||
ItemChangeDetails = "item-table-updater",
|
||||
}
|
||||
@@ -51,6 +58,7 @@ export type DialogParamsMap = {
|
||||
attachmentId: string;
|
||||
};
|
||||
[DialogID.CreateItem]?: { product?: BarcodeProduct };
|
||||
[DialogID.EditUser]?: { userId?: string };
|
||||
[DialogID.ProductImport]?: { barcode?: string };
|
||||
[DialogID.EditMaintenance]:
|
||||
| { type: "create"; itemId: string | string[] }
|
||||
@@ -70,7 +78,9 @@ export type DialogParamsMap = {
|
||||
export type DialogResultMap = {
|
||||
[DialogID.ItemImage]?: { action: "delete"; id: string };
|
||||
[DialogID.EditMaintenance]?: boolean;
|
||||
[DialogID.CreateInvite]?: boolean;
|
||||
[DialogID.ItemChangeDetails]?: boolean;
|
||||
[DialogID.EditUser]?: boolean;
|
||||
};
|
||||
|
||||
/** Helpers to split IDs by requirement */
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<ModalConfirm />
|
||||
<OutdatedModal v-if="status" :status="status" />
|
||||
<ItemCreateModal />
|
||||
<CreateInviteModal />
|
||||
<LabelCreateModal />
|
||||
<LocationCreateModal />
|
||||
<ItemBarcodeModal />
|
||||
@@ -24,6 +25,8 @@
|
||||
<AppLogo />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<AppCollectionSelector v-model:model-value="selectedCollectionId" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
@@ -119,12 +122,23 @@
|
||||
}"
|
||||
>
|
||||
<div class="flex h-1/2 items-center gap-2 sm:h-auto">
|
||||
<SidebarTrigger variant="default" />
|
||||
<div>
|
||||
<SidebarTrigger variant="default" />
|
||||
</div>
|
||||
<!-- <div>
|
||||
<Button size="icon">
|
||||
<AppLogo class="size-8" />
|
||||
</Button>
|
||||
</div> -->
|
||||
<NuxtLink to="/home">
|
||||
<AppHeaderText class="h-6" />
|
||||
</NuxtLink>
|
||||
<!-- <AppOrgSelector v-model:model-value="selectedOrg" /> -->
|
||||
</div>
|
||||
<div class="sm:grow" />
|
||||
<!-- <div class="flex items-center">
|
||||
<AppOrgSelector v-model:model-value="selectedOrg" />
|
||||
</div> -->
|
||||
<div class="flex h-1/2 grow items-center justify-end gap-2 sm:h-auto">
|
||||
<Input
|
||||
v-model:model-value="search"
|
||||
@@ -220,11 +234,15 @@
|
||||
import LabelCreateModal from "~/components/Label/CreateModal.vue";
|
||||
import LocationCreateModal from "~/components/Location/CreateModal.vue";
|
||||
import ItemBarcodeModal from "~/components/Item/BarcodeModal.vue";
|
||||
import CreateInviteModal from "~/components/Admin/CreateInviteModal.vue";
|
||||
import AppQuickMenuModal from "~/components/App/QuickMenuModal.vue";
|
||||
import AppScannerModal from "~/components/App/ScannerModal.vue";
|
||||
import AppLogo from "~/components/App/Logo.vue";
|
||||
import AppHeaderDecor from "~/components/App/HeaderDecor.vue";
|
||||
import AppHeaderText from "~/components/App/HeaderText.vue";
|
||||
import AppCollectionSelector from "~/components/App/CollectionSelector.vue";
|
||||
|
||||
const selectedCollectionId = ref<string>("c1");
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const username = computed(() => authCtx.user?.name || "User");
|
||||
@@ -359,6 +377,13 @@
|
||||
name: computed(() => t("menu.tools")),
|
||||
to: "/tools",
|
||||
},
|
||||
{
|
||||
icon: MdiAccount,
|
||||
id: 7,
|
||||
active: computed(() => route.path === "/admin"),
|
||||
name: computed(() => t("menu.admin")),
|
||||
to: "/admin",
|
||||
},
|
||||
];
|
||||
|
||||
const quickMenuActions = reactive([
|
||||
|
||||
216
frontend/mock/collections.ts
Normal file
216
frontend/mock/collections.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
export type Collection = { id: string; name: string };
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
created_at?: string;
|
||||
role: "admin" | "user" | string;
|
||||
password_set?: boolean;
|
||||
oidc_set?: boolean;
|
||||
collections: {
|
||||
id: string;
|
||||
role: "owner" | "admin" | "editor" | "viewer";
|
||||
}[];
|
||||
};
|
||||
|
||||
export const collections: Collection[] = [
|
||||
{ id: "c1", name: "Personal Inventory" },
|
||||
{ id: "c2", name: "Office Equipment" },
|
||||
{ id: "c3", name: "Workshop Tools" },
|
||||
];
|
||||
|
||||
export const users: User[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Alice Admin",
|
||||
email: "alice@example.com",
|
||||
created_at: new Date(new Date().setFullYear(new Date().getFullYear() - 2)).toISOString(),
|
||||
role: "admin",
|
||||
password_set: true,
|
||||
collections: [
|
||||
{ id: collections[0]!.id, role: "owner" },
|
||||
{ id: collections[1]!.id, role: "admin" },
|
||||
{ id: collections[2]!.id, role: "editor" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Bob User",
|
||||
email: "bob@example.com",
|
||||
created_at: new Date(new Date().setFullYear(new Date().getFullYear() - 1)).toISOString(),
|
||||
role: "user",
|
||||
password_set: true,
|
||||
oidc_set: true,
|
||||
collections: [
|
||||
{ id: collections[1]!.id, role: "owner" },
|
||||
{ id: collections[2]!.id, role: "admin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Charlie",
|
||||
email: "charlie@example.com",
|
||||
created_at: new Date().toISOString(),
|
||||
role: "user",
|
||||
password_set: false,
|
||||
// collections[3] was out of range (only 0..2 exist). Use collections[2].
|
||||
collections: [{ id: collections[2]!.id, role: "owner" }],
|
||||
},
|
||||
];
|
||||
|
||||
export type Invite = {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
role?: "owner" | "admin" | "editor" | "viewer";
|
||||
created_at?: string;
|
||||
expires_at?: string;
|
||||
max_uses?: number;
|
||||
uses?: number;
|
||||
};
|
||||
|
||||
export const invites: Invite[] = [
|
||||
{
|
||||
id: "i1",
|
||||
collectionId: collections[0]!.id,
|
||||
role: "viewer",
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(new Date().setDate(new Date().getDate() + 7)).toISOString(),
|
||||
max_uses: 5,
|
||||
uses: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// Simple in-memory fake API operating on the above arrays.
|
||||
export const api = {
|
||||
// by is the person who is requesting the collections, include the number of members and their role
|
||||
getCollections(by: string = "1") {
|
||||
const user = users.find(u => u.id === by);
|
||||
if (!user) return [];
|
||||
return user.collections
|
||||
.map(c => {
|
||||
const collection = collections.find(col => col.id === c.id);
|
||||
if (!collection) return null;
|
||||
// find number of people with access to this collection
|
||||
const count = users.reduce((acc, u) => {
|
||||
const hasAccess = u.collections.some(uc => uc.id === collection.id);
|
||||
return acc + (hasAccess ? 1 : 0);
|
||||
}, 0);
|
||||
return {
|
||||
...collection,
|
||||
count,
|
||||
role: c.role,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
},
|
||||
getUsers(): User[] {
|
||||
return users;
|
||||
},
|
||||
getInvites(): Invite[] {
|
||||
return invites;
|
||||
},
|
||||
getUser(id: string) {
|
||||
return users.find(u => u.id === id);
|
||||
},
|
||||
addUser(input: Partial<User>) {
|
||||
const u: User = {
|
||||
id: input.id ?? String(Date.now()),
|
||||
name: input.name ?? "",
|
||||
email: input.email ?? "",
|
||||
role: input.role ?? "user",
|
||||
password_set: input.password_set ?? false,
|
||||
oidc_set: input.oidc_set ?? false,
|
||||
collections: input.collections ?? [],
|
||||
};
|
||||
users.unshift(u);
|
||||
return u;
|
||||
},
|
||||
updateUser(updated: User) {
|
||||
const idx = users.findIndex(u => u.id === updated.id);
|
||||
if (idx >= 0) users.splice(idx, 1, { ...updated });
|
||||
return updated;
|
||||
},
|
||||
deleteUser(id: string) {
|
||||
const idx = users.findIndex(u => u.id === id);
|
||||
if (idx >= 0) {
|
||||
users.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
addInvite(input: Partial<Invite>) {
|
||||
const inv: Invite = {
|
||||
id: input.id ?? `i${Date.now()}`,
|
||||
collectionId: input.collectionId ?? collections[0]!.id,
|
||||
role: input.role ?? "viewer",
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: input.expires_at ? input.expires_at : undefined,
|
||||
max_uses: input.max_uses ? input.max_uses : undefined,
|
||||
uses: 0,
|
||||
};
|
||||
invites.unshift(inv);
|
||||
return inv;
|
||||
},
|
||||
deleteInvite(id: string) {
|
||||
const idx = invites.findIndex(i => i.id === id);
|
||||
if (idx >= 0) invites.splice(idx, 1);
|
||||
return idx >= 0;
|
||||
},
|
||||
addCollection(input: Partial<Collection>) {
|
||||
const col: Collection = { id: input.id ?? `c${Date.now()}`, name: input.name ?? "New Collection" };
|
||||
collections.push(col);
|
||||
// add user[0] to collection
|
||||
users[0]!.collections.push({ id: col.id, role: "owner" });
|
||||
return col;
|
||||
},
|
||||
updateCollection(updated: Collection) {
|
||||
const idx = collections.findIndex(c => c.id === updated.id);
|
||||
if (idx >= 0) collections.splice(idx, 1, { ...updated });
|
||||
return updated;
|
||||
},
|
||||
addUserToCollection(userId: string, collectionId: string, role: "owner" | "admin" | "editor" | "viewer" = "viewer") {
|
||||
const u = users.find(x => x.id === userId);
|
||||
if (!u) return null;
|
||||
const exists = u.collections.find(c => c.id === collectionId);
|
||||
if (exists) {
|
||||
exists.role = role;
|
||||
return exists;
|
||||
}
|
||||
const mem = { id: collectionId, role } as { id: string; role: "owner" | "admin" | "editor" | "viewer" };
|
||||
u.collections.push(mem);
|
||||
return mem;
|
||||
},
|
||||
removeUserFromCollection(userId: string, collectionId: string) {
|
||||
const u = users.find(x => x.id === userId);
|
||||
if (!u) return false;
|
||||
const idx = u.collections.findIndex(c => c.id === collectionId);
|
||||
if (idx >= 0) {
|
||||
const wasOwner = u.collections[idx]!.role === "owner";
|
||||
u.collections.splice(idx, 1);
|
||||
// if removed owner, and no other owners exist for that collection, delete the collection
|
||||
if (wasOwner) {
|
||||
const stillOwner = users.some(other =>
|
||||
(other.collections ?? []).some(c => c.id === collectionId && c.role === "owner")
|
||||
);
|
||||
if (!stillOwner) {
|
||||
// remove collection
|
||||
const cidx = collections.findIndex(c => c.id === collectionId);
|
||||
if (cidx >= 0) collections.splice(cidx, 1);
|
||||
// remove membership from all users
|
||||
users.forEach(mu => {
|
||||
mu.collections = (mu.collections ?? []).filter(c => c.id !== collectionId);
|
||||
});
|
||||
// remove invites to that collection
|
||||
for (let i = invites.length - 1; i >= 0; i--) {
|
||||
if (invites[i]!.collectionId === collectionId) invites.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
export default { collections, users, invites, api };
|
||||
199
frontend/pages/admin.vue
Normal file
199
frontend/pages/admin.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<!-- TODO:
|
||||
- make collection on hover show role and colour based on role
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useConfirm } from "~/composables/use-confirm";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableEmpty } from "~/components/ui/table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import BaseContainer from "@/components/Base/Container.vue";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import MdiPencil from "~icons/mdi/pencil";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
// import MdiOpenInNew from "~icons/mdi/open-in-new";
|
||||
// Badge component for collections display
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import UserFormDialog from "@/components/Admin/UserFormDialog.vue";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
|
||||
import { api, type Collection as MockCollection, type User } from "~/mock/collections";
|
||||
|
||||
// api.getCollections returns collections augmented with `count` and the current user's `role`
|
||||
type CollectionSummary = MockCollection & { count: number; role: User["collections"][number]["role"] };
|
||||
|
||||
const collections = ref<CollectionSummary[]>(api.getCollections() as CollectionSummary[]);
|
||||
const users = ref<User[]>(api.getUsers());
|
||||
|
||||
const query = ref("");
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return users.value;
|
||||
return users.value.filter(u => {
|
||||
return `${u.name} ${u.email} ${u.role}`.toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
const confirm = useConfirm();
|
||||
const { t } = useI18n();
|
||||
|
||||
// editing state handled in dialog component; role toggle logic applied on save
|
||||
|
||||
// helper to compute auth type for display
|
||||
// authType removed — not used in the template
|
||||
|
||||
function authType(u: User) {
|
||||
const parts: string[] = [];
|
||||
if (u.password_set) parts.push("Password");
|
||||
if (u.oidc_subject) parts.push("OIDC");
|
||||
return parts.length ? parts.join(" & ") : "None";
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
openDialog(DialogID.EditUser, {
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
users.value = api.getUsers();
|
||||
collections.value = api.getCollections();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openEdit(u: User) {
|
||||
openDialog(DialogID.EditUser, {
|
||||
params: { userId: u.id },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
users.value = api.getUsers();
|
||||
collections.value = api.getCollections();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmDelete(u: User) {
|
||||
const { isCanceled } = await confirm.open({
|
||||
message: t("global.delete_confirm") + " " + `${u.name} (${u.email})?`,
|
||||
});
|
||||
if (isCanceled) return;
|
||||
|
||||
api.deleteUser(u.id);
|
||||
users.value = api.getUsers();
|
||||
// TODO: call backend API to delete user when available
|
||||
}
|
||||
|
||||
// no more toggleActive; active is not used
|
||||
|
||||
function collectionName(id: string) {
|
||||
const col = collections.value.find(c => c.id === id);
|
||||
return col ? col.name : id;
|
||||
}
|
||||
|
||||
function roleVariant(role: string | undefined) {
|
||||
if (role === "owner") return "default";
|
||||
if (role === "admin") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
// dialog handles editing state now via dialog provider
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseContainer class="flex flex-col gap-4">
|
||||
<BaseSectionHeader>
|
||||
<span>User Management</span>
|
||||
<div class="ml-auto">
|
||||
<Button @click="openAdd">Add User</Button>
|
||||
</div>
|
||||
</BaseSectionHeader>
|
||||
|
||||
<Card class="p-0">
|
||||
<Table class="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="min-w-[160px]">{{ t("global.name") }}</TableHead>
|
||||
<TableHead class="min-w-[220px]">{{ t("global.email") }}</TableHead>
|
||||
<TableHead class="min-w-[96px] text-center">Is Admin</TableHead>
|
||||
<TableHead class="min-w-[220px]">Collections</TableHead>
|
||||
<TableHead class="min-w-[96px] text-center">{{ t("global.details") }}</TableHead>
|
||||
<TableHead class="w-40 text-center"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<template v-if="filtered.length">
|
||||
<TableRow v-for="u in filtered" :key="u.id">
|
||||
<TableCell>{{ u.name }}</TableCell>
|
||||
<TableCell>{{ u.email }}</TableCell>
|
||||
<TableCell class="text-center align-middle">
|
||||
<div class="flex size-full items-center justify-center font-medium">
|
||||
<MdiCheck v-if="u.role === 'admin'" class="text-primary" />
|
||||
<MdiClose v-else class="text-destructive" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<template v-if="u.collections && u.collections.length">
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<template v-for="c in u.collections" :key="c.id">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Badge class="whitespace-nowrap" :variant="roleVariant(c.role)">{{
|
||||
collectionName(c.id)
|
||||
}}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p class="text-sm">{{ c.role }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center align-middle">
|
||||
<div class="flex size-full items-center justify-center">
|
||||
<span>{{ authType(u) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right align-middle">
|
||||
<div class="flex size-full items-center justify-end gap-2">
|
||||
<Button size="icon" variant="outline" class="size-8" :title="t('global.edit')" @click="openEdit(u)">
|
||||
<MdiPencil class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
class="size-8"
|
||||
:title="t('global.delete')"
|
||||
@click="confirmDelete(u)"
|
||||
>
|
||||
<MdiDelete class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<TableEmpty :colspan="6">
|
||||
<p>{{ $t("items.selector.no_results") }}</p>
|
||||
</TableEmpty>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<!-- Add / Edit form modal (moved to component) -->
|
||||
<UserFormDialog />
|
||||
</BaseContainer>
|
||||
</template>
|
||||
225
frontend/pages/collection.vue
Normal file
225
frontend/pages/collection.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { api, type User as MockUser, type Invite as MockInvite } from "~/mock/collections";
|
||||
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
// Popover removed from invite UI; no longer importing
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { Card } from "@/components/ui/card"; // Assuming you have a Card component
|
||||
import { Badge } from "@/components/ui/badge"; // Assuming you have a Badge component
|
||||
import { PlusCircle, Trash } from "lucide-vue-next"; // Icons
|
||||
import { format } from "date-fns";
|
||||
import CopyText from "@/components/global/CopyText.vue";
|
||||
import BaseContainer from "@/components/Base/Container.vue";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import { useDialog } from "~/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["auth"],
|
||||
});
|
||||
useHead({
|
||||
title: "HomeBox | " + t("menu.maintenance"),
|
||||
});
|
||||
|
||||
// Use centralized mock data / fake API
|
||||
const users = ref<MockUser[]>(api.getUsers());
|
||||
const invites = ref<MockInvite[]>(api.getInvites());
|
||||
|
||||
// Current collection context (this page shows a single collection)
|
||||
// For now use the first mock collection as the active collection
|
||||
const currentCollectionId = api.getCollections()[0]?.id ?? "";
|
||||
|
||||
// New invite email input
|
||||
// (declared below with invite inputs)
|
||||
|
||||
// Settings state
|
||||
const collectionName = ref<string>("Personal Inventory");
|
||||
const saved = ref(false);
|
||||
|
||||
// invite inputs (moved to dialog)
|
||||
|
||||
const page = ref(1);
|
||||
|
||||
const roles = ["owner", "admin", "editor", "viewer"];
|
||||
|
||||
function inviteUrl(code: string) {
|
||||
if (typeof window === "undefined") return "";
|
||||
return `${window.location.origin}?token=${code}`;
|
||||
}
|
||||
|
||||
function getMembershipRole(user: MockUser) {
|
||||
const mem = user.collections.find(c => c.id === currentCollectionId);
|
||||
return mem?.role ?? "viewer";
|
||||
}
|
||||
|
||||
function roleVariant(role: string) {
|
||||
return role === "owner" ? "default" : role === "admin" ? "secondary" : "outline";
|
||||
}
|
||||
|
||||
function handleRoleChange(userId: string, newRole: unknown) {
|
||||
// Update the role for this user specific to the current collection
|
||||
const roleStr = String(newRole || "viewer");
|
||||
api.addUserToCollection(userId, currentCollectionId, roleStr as MockUser["collections"][number]["role"]);
|
||||
users.value = api.getUsers();
|
||||
}
|
||||
|
||||
function handleRemoveUser(userId: string) {
|
||||
api.deleteUser(userId);
|
||||
users.value = api.getUsers();
|
||||
}
|
||||
|
||||
// Invite creation now handled by dialog component; keep helper removed.
|
||||
|
||||
function deleteInvite(inviteId: string) {
|
||||
api.deleteInvite(inviteId);
|
||||
invites.value = api.getInvites();
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
// Stub: persist settings to API when implemented
|
||||
console.log("Saving collection settings", collectionName.value);
|
||||
saved.value = true;
|
||||
setTimeout(() => (saved.value = false), 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseContainer class="flex flex-col gap-4">
|
||||
<BaseSectionHeader> Collection Settings </BaseSectionHeader>
|
||||
<ButtonGroup>
|
||||
<Button size="sm" :variant="page == 1 ? 'default' : 'outline'" @click="page = 1"> Users </Button>
|
||||
<Button size="sm" :variant="page == 2 ? 'default' : 'outline'" @click="page = 2"> Invites </Button>
|
||||
<Button size="sm" :variant="page == 3 ? 'default' : 'outline'" @click="page = 3"> Settings </Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<Card v-if="page == 1" class="">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="user in users" :key="user.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ user.name }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
:model-value="getMembershipRole(user)"
|
||||
@update:model-value="newRole => handleRoleChange(user.id, newRole)"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span class="flex items-center">
|
||||
<Badge class="whitespace-nowrap" :variant="roleVariant(getMembershipRole(user))">{{
|
||||
getMembershipRole(user)
|
||||
}}</Badge>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="role in roles" :key="role" :value="role">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<Badge
|
||||
class="whitespace-nowrap"
|
||||
:variant="role === 'owner' ? 'default' : role === 'admin' ? 'secondary' : 'outline'"
|
||||
>
|
||||
{{ role }}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ (user as any).created_at ? format(new Date((user as any).created_at), "PPP") : "-" }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<div class="flex w-full items-center justify-end gap-2">
|
||||
<Button variant="destructive" size="icon" @click="handleRemoveUser(user.id)">
|
||||
<Trash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Card v-if="page == 2" class="p-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="text-lg font-semibold">Existing Invites</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Max Uses</TableHead>
|
||||
<TableHead>Uses</TableHead>
|
||||
<TableHead class="text-right"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="invite in invites" :key="invite.id">
|
||||
<TableCell class="font-medium">{{ invite.id }}</TableCell>
|
||||
<TableCell>{{ invite.expires_at ? format(new Date(invite.expires_at), "PPP") : "Never" }}</TableCell>
|
||||
<TableCell>{{ invite.max_uses ?? "∞" }}</TableCell>
|
||||
<TableCell>{{ invite.uses ?? 0 }}</TableCell>
|
||||
<TableCell class="w-max">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<CopyText :text="inviteUrl(invite.id)" />
|
||||
<Button variant="destructive" size="icon" @click="deleteInvite(invite.id)">
|
||||
<Trash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Create New Invite</h3>
|
||||
<div class="w-56">
|
||||
<Button
|
||||
class="w-full"
|
||||
@click="openDialog(DialogID.CreateInvite, { onClose: () => (invites.value = api.getInvites()) })"
|
||||
>
|
||||
<PlusCircle class="mr-2 size-4" /> Generate Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card v-if="page == 3" class="p-4">
|
||||
<h3 class="text-lg font-semibold">Collection Settings</h3>
|
||||
|
||||
<div class="mt-4 grid items-end gap-4 md:grid-cols-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="collection-name">Name</Label>
|
||||
<Input id="collection-name" v-model="collectionName" placeholder="Collection name" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button class="w-full" @click="saveSettings">Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="saved" class="mt-3 text-sm text-green-600">Saved</p>
|
||||
</Card>
|
||||
</BaseContainer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* HomeBox Upgrade Verification Tests
|
||||
*
|
||||
* NOTE: These tests are ONLY meant to run in the upgrade-test workflow.
|
||||
* They require test data to be pre-created by the create-test-data.sh script.
|
||||
* These tests are stored in test/upgrade/ (not test/e2e/) to prevent them
|
||||
* from running during normal E2E test runs.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
|
||||
// Load test data created by the setup script
|
||||
const testDataPath = process.env.TEST_DATA_FILE || "/tmp/test-users.json";
|
||||
|
||||
interface TestUser {
|
||||
email: string;
|
||||
password: string;
|
||||
token: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface TestData {
|
||||
users?: TestUser[];
|
||||
locations?: Record<string, string[]>;
|
||||
labels?: Record<string, string[]>;
|
||||
items?: Record<string, string[]>;
|
||||
notifiers?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
let testData: TestData = {};
|
||||
|
||||
test.beforeAll(() => {
|
||||
if (fs.existsSync(testDataPath)) {
|
||||
const rawData = fs.readFileSync(testDataPath, "utf-8");
|
||||
testData = JSON.parse(rawData);
|
||||
console.log("Loaded test data:", JSON.stringify(testData, null, 2));
|
||||
} else {
|
||||
console.error(`Test data file not found at ${testDataPath}`);
|
||||
throw new Error("Test data file not found");
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("HomeBox Upgrade Verification", () => {
|
||||
test("verify all users can log in", async ({ page }) => {
|
||||
// Test each user from the test data
|
||||
for (const user of testData.users || []) {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// Wait for login form to be ready
|
||||
await page.waitForSelector("input[type='text']", { state: "visible" });
|
||||
|
||||
// Fill in login form
|
||||
await page.fill("input[type='text']", user.email);
|
||||
await page.fill("input[type='password']", user.password);
|
||||
await page.click("button[type='submit']");
|
||||
|
||||
// Wait for navigation to home page
|
||||
await expect(page).toHaveURL("/home", { timeout: 10000 });
|
||||
|
||||
console.log(`✓ User ${user.email} logged in successfully`);
|
||||
|
||||
// Navigate back to login for next user
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("input[type='text']", { state: "visible" });
|
||||
}
|
||||
});
|
||||
|
||||
test("verify application version is displayed", async ({ page }) => {
|
||||
// Login as first user
|
||||
const firstUser = testData.users?.[0];
|
||||
if (!firstUser) {
|
||||
throw new Error("No users found in test data");
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
await page.fill("input[type='text']", firstUser.email);
|
||||
await page.fill("input[type='password']", firstUser.password);
|
||||
await page.click("button[type='submit']");
|
||||
await expect(page).toHaveURL("/home", { timeout: 10000 });
|
||||
|
||||
// Look for version in footer or about section
|
||||
// The version might be in the footer or a settings page
|
||||
// Check if footer exists and contains version info
|
||||
const footer = page.locator("footer");
|
||||
if ((await footer.count()) > 0) {
|
||||
const footerText = await footer.textContent();
|
||||
console.log("Footer text:", footerText);
|
||||
|
||||
// Version should be present in some form
|
||||
// This is a basic check - the version format may vary
|
||||
expect(footerText).toBeTruthy();
|
||||
}
|
||||
|
||||
console.log("✓ Application version check complete");
|
||||
});
|
||||
|
||||
test("verify locations are present", async ({ page }) => {
|
||||
const firstUser = testData.users?.[0];
|
||||
if (!firstUser) {
|
||||
throw new Error("No users found in test data");
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
await page.fill("input[type='text']", firstUser.email);
|
||||
await page.fill("input[type='password']", firstUser.password);
|
||||
await page.click("button[type='submit']");
|
||||
await expect(page).toHaveURL("/home", { timeout: 10000 });
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForSelector("body", { state: "visible" });
|
||||
|
||||
// Try to find locations link in navigation
|
||||
const locationsLink = page.locator("a[href*='location'], button:has-text('Locations')").first();
|
||||
|
||||
if ((await locationsLink.count()) > 0) {
|
||||
await locationsLink.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check if locations are displayed
|
||||
// The exact structure depends on the UI, but we should see location names
|
||||
const pageContent = await page.textContent("body");
|
||||
|
||||
// Verify some of our test locations exist
|
||||
expect(pageContent).toContain("Living Room");
|
||||
console.log("✓ Locations verified");
|
||||
} else {
|
||||
console.log("! Could not find locations navigation - skipping detailed check");
|
||||
}
|
||||
});
|
||||
|
||||
test("verify labels are present", async ({ page }) => {
|
||||
const firstUser = testData.users?.[0];
|
||||
if (!firstUser) {
|
||||
throw new Error("No users found in test data");
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
await page.fill("input[type='text']", firstUser.email);
|
||||
await page.fill("input[type='password']", firstUser.password);
|
||||
await page.click("button[type='submit']");
|
||||
await expect(page).toHaveURL("/home", { timeout: 10000 });
|
||||
|
||||
await page.waitForSelector("body", { state: "visible" });
|
||||
|
||||
// Try to find labels link in navigation
|
||||
const labelsLink = page.locator("a[href*='label'], button:has-text('Labels')").first();
|
||||
|
||||
if ((await labelsLink.count()) > 0) {
|
||||
await labelsLink.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const pageContent = await page.textContent("body");
|
||||
|
||||
// Verify some of our test labels exist
|
||||
expect(pageContent).toContain("Electronics");
|
||||
console.log("✓ Labels verified");
|
||||
} else {
|
||||
console.log("! Could not find labels navigation - skipping detailed check");
|
||||
}
|
||||
});
|
||||
|
||||
test("verify items are present", async ({ page }) => {
|
||||
const firstUser = testData.users?.[0];
|
||||
if (!firstUser) {
|
||||
throw new Error("No users found in test data");
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
await page.fill("input[type='text']", firstUser.email);
|
||||
await page.fill("input[type='password']", firstUser.password);
|
||||
await page.click("button[type='submit']");
|
||||
await expect(page).toHaveURL("/home", { timeout: 10000 });
|
||||
|
||||
await page.waitForSelector("body", { state: "visible" });
|
||||
|
||||
// Navigate to items list
|
||||
// This might be the home page or a separate items page
|
||||
const itemsLink = page.locator("a[href*='item'], button:has-text('Items')").first();
|
||||
|
||||
if ((await itemsLink.count()) > 0) {
|
||||
await itemsLink.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
const pageContent = await page.textContent("body");
|
||||
|
||||
// Verify some of our test items exist
|
||||
expect(pageContent).toContain("Laptop Computer");
|
||||
console.log("✓ Items verified");
|
||||
});
|
||||
|
||||
test("verify notifier is present", async ({ page }) => {
|
||||
const firstUser = testData.users?.[0];
|
||||
if (!firstUser) {
|
||||
throw new Error("No users found in test data");
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
await page.fill("input[type='text']", firstUser.email);
|
||||
await page.fill("input[type='password']", firstUser.password);
|
||||
await page.click("button[type='submit']");
|
||||
await expect(page).toHaveURL("/home", { timeout: 10000 });
|
||||
|
||||
await page.waitForSelector("body", { state: "visible" });
|
||||
|
||||
// Navigate to settings or profile
|
||||
// Notifiers are typically in settings
|
||||
const settingsLink = page.locator("a[href*='setting'], a[href*='profile'], button:has-text('Settings')").first();
|
||||
|
||||
if ((await settingsLink.count()) > 0) {
|
||||
await settingsLink.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for notifiers section
|
||||
const notifiersLink = page.locator("a:has-text('Notif'), button:has-text('Notif')").first();
|
||||
|
||||
if ((await notifiersLink.count()) > 0) {
|
||||
await notifiersLink.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const pageContent = await page.textContent("body");
|
||||
|
||||
// Verify our test notifier exists
|
||||
expect(pageContent).toContain("TESTING");
|
||||
console.log("✓ Notifier verified");
|
||||
} else {
|
||||
console.log("! Could not find notifiers section - skipping detailed check");
|
||||
}
|
||||
} else {
|
||||
console.log("! Could not find settings navigation - skipping notifier check");
|
||||
}
|
||||
});
|
||||
|
||||
test("verify attachments are present for items", async ({ page }) => {
|
||||
const firstUser = testData.users?.[0];
|
||||
if (!firstUser) {
|
||||
throw new Error("No users found in test data");
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
await page.fill("input[type='text']", firstUser.email);
|
||||
await page.fill("input[type='password']", firstUser.password);
|
||||
await page.click("button[type='submit']");
|
||||
await expect(page).toHaveURL("/home", { timeout: 10000 });
|
||||
|
||||
await page.waitForSelector("body", { state: "visible" });
|
||||
|
||||
// Search for "Laptop Computer" which should have attachments
|
||||
const searchInput = page.locator("input[type='search'], input[placeholder*='Search']").first();
|
||||
|
||||
if ((await searchInput.count()) > 0) {
|
||||
await searchInput.fill("Laptop Computer");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Click on the laptop item
|
||||
const laptopItem = page.locator("text=Laptop Computer").first();
|
||||
await laptopItem.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for attachments section
|
||||
const pageContent = await page.textContent("body");
|
||||
|
||||
// Check for attachment indicators (could be files, documents, attachments, etc.)
|
||||
const hasAttachments =
|
||||
pageContent?.includes("laptop-receipt") ||
|
||||
pageContent?.includes("laptop-warranty") ||
|
||||
pageContent?.includes("attachment") ||
|
||||
pageContent?.includes("Attachment") ||
|
||||
pageContent?.includes("document");
|
||||
|
||||
expect(hasAttachments).toBeTruthy();
|
||||
console.log("✓ Attachments verified");
|
||||
} else {
|
||||
console.log("! Could not find search - trying direct navigation");
|
||||
|
||||
// Try alternative: look for items link and browse
|
||||
const itemsLink = page.locator("a[href*='item'], button:has-text('Items')").first();
|
||||
if ((await itemsLink.count()) > 0) {
|
||||
await itemsLink.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const laptopLink = page.locator("text=Laptop Computer").first();
|
||||
if ((await laptopLink.count()) > 0) {
|
||||
await laptopLink.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const pageContent = await page.textContent("body");
|
||||
const hasAttachments =
|
||||
pageContent?.includes("laptop-receipt") ||
|
||||
pageContent?.includes("laptop-warranty") ||
|
||||
pageContent?.includes("attachment");
|
||||
|
||||
expect(hasAttachments).toBeTruthy();
|
||||
console.log("✓ Attachments verified via direct navigation");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("verify theme can be adjusted", async ({ page }) => {
|
||||
const firstUser = testData.users?.[0];
|
||||
if (!firstUser) {
|
||||
throw new Error("No users found in test data");
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
await page.fill("input[type='text']", firstUser.email);
|
||||
await page.fill("input[type='password']", firstUser.password);
|
||||
await page.click("button[type='submit']");
|
||||
await expect(page).toHaveURL("/home", { timeout: 10000 });
|
||||
|
||||
await page.waitForSelector("body", { state: "visible" });
|
||||
|
||||
// Look for theme toggle (usually a sun/moon icon or settings)
|
||||
// Common selectors for theme toggles
|
||||
const themeToggle = page
|
||||
.locator(
|
||||
"button[aria-label*='theme'], button[aria-label*='Theme'], " +
|
||||
"button:has-text('Dark'), button:has-text('Light'), " +
|
||||
"[data-theme-toggle], .theme-toggle"
|
||||
)
|
||||
.first();
|
||||
|
||||
if ((await themeToggle.count()) > 0) {
|
||||
// Get initial theme state (could be from class, attribute, or computed style)
|
||||
const bodyBefore = page.locator("body");
|
||||
const classNameBefore = (await bodyBefore.getAttribute("class")) || "";
|
||||
|
||||
// Click theme toggle
|
||||
await themeToggle.click();
|
||||
// Wait for theme change to complete
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get theme state after toggle
|
||||
const classNameAfter = (await bodyBefore.getAttribute("class")) || "";
|
||||
|
||||
// Verify that something changed
|
||||
expect(classNameBefore).not.toBe(classNameAfter);
|
||||
|
||||
console.log(`✓ Theme toggle working (${classNameBefore} -> ${classNameAfter})`);
|
||||
} else {
|
||||
// Try to find theme in settings
|
||||
const settingsLink = page.locator("a[href*='setting'], a[href*='profile']").first();
|
||||
|
||||
if ((await settingsLink.count()) > 0) {
|
||||
await settingsLink.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const themeOption = page.locator("select[name*='theme'], button:has-text('Theme')").first();
|
||||
|
||||
if ((await themeOption.count()) > 0) {
|
||||
console.log("✓ Theme settings found");
|
||||
} else {
|
||||
console.log("! Could not find theme toggle - feature may not be easily accessible");
|
||||
}
|
||||
} else {
|
||||
console.log("! Could not find theme controls");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("verify data counts match expectations", async ({ page }) => {
|
||||
const firstUser = testData.users?.[0];
|
||||
if (!firstUser) {
|
||||
throw new Error("No users found in test data");
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
await page.fill("input[type='text']", firstUser.email);
|
||||
await page.fill("input[type='password']", firstUser.password);
|
||||
await page.click("button[type='submit']");
|
||||
await expect(page).toHaveURL("/home", { timeout: 10000 });
|
||||
|
||||
await page.waitForSelector("body", { state: "visible" });
|
||||
|
||||
// Check that we have the expected number of items for group 1 (5 items)
|
||||
const pageContent = await page.textContent("body");
|
||||
|
||||
// Look for item count indicators
|
||||
// This is dependent on the UI showing counts
|
||||
console.log("✓ Logged in and able to view dashboard");
|
||||
|
||||
// Verify at least that the page loaded and shows some content
|
||||
expect(pageContent).toBeTruthy();
|
||||
if (pageContent) {
|
||||
expect(pageContent.length).toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
test("verify second group users and data isolation", async ({ page }) => {
|
||||
// Login as user from group 2
|
||||
const group2User = testData.users?.find(u => u.group === "2");
|
||||
if (!group2User) {
|
||||
console.log("! No group 2 users found - skipping isolation test");
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
await page.fill("input[type='text']", group2User.email);
|
||||
await page.fill("input[type='password']", group2User.password);
|
||||
await page.click("button[type='submit']");
|
||||
await expect(page).toHaveURL("/home", { timeout: 10000 });
|
||||
|
||||
await page.waitForSelector("body", { state: "visible" });
|
||||
|
||||
const pageContent = await page.textContent("body");
|
||||
|
||||
// Verify group 2 can see their items
|
||||
expect(pageContent).toContain("Monitor");
|
||||
|
||||
// Verify group 2 cannot see group 1 items
|
||||
expect(pageContent).not.toContain("Laptop Computer");
|
||||
|
||||
console.log("✓ Data isolation verified between groups");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user