Compare commits

..

17 Commits

Author SHA1 Message Date
Katos
87c1f0333f Fix test data 2025-12-27 21:36:26 +00:00
Katos
570ad8fbf8 Attempt to fix tests action 2025-12-27 21:32:15 +00:00
Harrison Conlin
f0b8bb8b7f refactor(backend): use constants for database driver names (#1177)
magic constants are bad m'kay
2025-12-27 16:16:48 -05:00
Katos
ecc9fa1959 Disable triggers in upgrade-test.yaml
Comment out the workflow triggers in upgrade-test.yaml
2025-12-27 20:28:31 +00:00
Katos
7068a85dfb Merge pull request #1178 from sysadminsmedia/copilot/create-ci-cd-pipeline
Add CI/CD workflow for upgrade testing with data integrity verification
2025-12-27 20:18:37 +00:00
Katos
c73922c754 Merge branch 'main' into copilot/create-ci-cd-pipeline 2025-12-27 20:16:41 +00:00
Matthew Kilgore
ae2179c01c Add blog link 2025-12-27 12:05:05 -05:00
copilot-swe-agent[bot]
09e056a3fb Move upgrade verification tests to separate directory
- Move upgrade-verification.spec.ts from test/e2e/ to test/upgrade/
- This prevents the test from running during normal E2E CI runs
- The upgrade test is only meant for the upgrade-test workflow
- Update workflow and documentation to reflect new location

Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-27 16:12:51 +00:00
Katos
4abfc76865 Fix CodeRabbit date quoting issue 2025-12-27 16:01:57 +00:00
copilot-swe-agent[bot]
afd7a10003 Fix TypeScript null check in upgrade-verification test
Add null check for pageContent before accessing length property

Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-27 14:34:59 +00:00
copilot-swe-agent[bot]
8eedd1e39d Fix ESLint errors in upgrade-verification.spec.ts
- Remove unused 'path' import
- Replace 'any' types with proper TypeScript interfaces
- Fix all Prettier formatting issues

Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-27 14:32:57 +00:00
copilot-swe-agent[bot]
fedeb1a7e5 Add proper GITHUB_TOKEN permissions to workflow
Set minimal required permissions (contents:read, packages:read) to follow security best practices

Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-27 14:14:52 +00:00
copilot-swe-agent[bot]
69b31a3be5 Improve test reliability and fix security issues
- Replace waitForTimeout with waitForSelector and waitForLoadState
- Remove eval security risk in bash script
- Use proper wait mechanisms for better test reliability

Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-27 14:12:22 +00:00
copilot-swe-agent[bot]
31d306ca05 Add comprehensive documentation for upgrade test workflow
Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-27 14:09:37 +00:00
copilot-swe-agent[bot]
1bfb716cea Add upgrade test workflow with data generation and verification
Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-27 14:08:13 +00:00
copilot-swe-agent[bot]
13b1524c56 Initial plan for upgrade test workflow
Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-27 14:06:05 +00:00
copilot-swe-agent[bot]
b18599b6f4 Initial plan 2025-12-27 14:02:31 +00:00
33 changed files with 1043 additions and 1687 deletions

259
.github/scripts/upgrade-test/README.md vendored Normal file
View File

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

View File

@@ -0,0 +1,153 @@
#!/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 Normal file
View File

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

View File

@@ -108,7 +108,7 @@ func run(cfg *config.Config) error {
return err
}
if strings.ToLower(cfg.Database.Driver) == "postgres" {
if strings.ToLower(cfg.Database.Driver) == config.DriverPostgres {
if !validatePostgresSSLMode(cfg.Database.SslMode) {
log.Error().Str("sslmode", cfg.Database.SslMode).Msg("invalid sslmode")
return fmt.Errorf("invalid sslmode: %s", cfg.Database.SslMode)

View File

@@ -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 "sqlite3":
case config.DriverSqlite3:
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 "postgres":
case config.DriverPostgres:
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)

View File

@@ -10,31 +10,22 @@ 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/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/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
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/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
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=
@@ -88,8 +79,6 @@ 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=
@@ -183,14 +172,10 @@ 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=
@@ -210,16 +195,10 @@ 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=
@@ -249,8 +228,6 @@ 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=
@@ -290,8 +267,6 @@ 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=
@@ -362,8 +337,6 @@ 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=
@@ -380,8 +353,6 @@ 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=
@@ -437,8 +408,6 @@ 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=
@@ -476,26 +445,16 @@ 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=
@@ -516,21 +475,13 @@ 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=
@@ -540,18 +491,12 @@ 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=
@@ -567,8 +512,6 @@ 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=
@@ -578,8 +521,6 @@ 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=
@@ -587,8 +528,6 @@ 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=
@@ -597,28 +536,16 @@ 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=
@@ -640,8 +567,6 @@ 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=
@@ -652,8 +577,6 @@ 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=

View File

@@ -4,6 +4,7 @@ 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"
)
@@ -24,7 +25,7 @@ func AccentInsensitiveContains(field string, searchValue string) predicate.Item
dialect := s.Dialect()
switch dialect {
case "sqlite3":
case conf.DriverSqlite3:
// For SQLite, we'll create a custom normalization function using REPLACE
// to handle common accented characters
normalizeFunc := buildSQLiteNormalizeExpression(s.C(field))
@@ -32,7 +33,7 @@ func AccentInsensitiveContains(field string, searchValue string) predicate.Item
"LOWER("+normalizeFunc+") LIKE ?",
"%"+normalizedSearch+"%",
))
case "postgres":
case conf.DriverPostgres:
// 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.)

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
)
//go:embed all:postgres
@@ -21,9 +22,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 "postgres":
case config.DriverPostgres:
return postgresFiles, nil
case "sqlite3":
case config.DriverSqlite3:
return sqliteFiles, nil
default:
log.Error().Str("dialect", dialect).Msg("unknown sql dialect")

View File

@@ -1,7 +1,8 @@
package config
const (
DriverSqlite3 = "sqlite3"
DriverSqlite3 = "sqlite3"
DriverPostgres = "postgres"
)
type Storage struct {

View File

@@ -43,6 +43,7 @@ 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: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
<script lang="ts" setup>
import { CalendarGridBody, type CalendarGridBodyProps } from 'reka-ui'
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody v-bind="props">
<slot />
</CalendarGridBody>
</template>

View File

@@ -1,11 +0,0 @@
<script lang="ts" setup>
import { CalendarGridHead, type CalendarGridHeadProps } from 'reka-ui'
const props = defineProps<CalendarGridHeadProps>()
</script>
<template>
<CalendarGridHead v-bind="props">
<slot />
</CalendarGridHead>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,7 @@
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",
@@ -29,8 +24,6 @@ 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",
}
@@ -58,7 +51,6 @@ export type DialogParamsMap = {
attachmentId: string;
};
[DialogID.CreateItem]?: { product?: BarcodeProduct };
[DialogID.EditUser]?: { userId?: string };
[DialogID.ProductImport]?: { barcode?: string };
[DialogID.EditMaintenance]:
| { type: "create"; itemId: string | string[] }
@@ -78,9 +70,7 @@ 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 */

View File

@@ -8,7 +8,6 @@
<ModalConfirm />
<OutdatedModal v-if="status" :status="status" />
<ItemCreateModal />
<CreateInviteModal />
<LabelCreateModal />
<LocationCreateModal />
<ItemBarcodeModal />
@@ -25,8 +24,6 @@
<AppLogo />
</div>
</NuxtLink>
<AppCollectionSelector v-model:model-value="selectedCollectionId" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
@@ -122,23 +119,12 @@
}"
>
<div class="flex h-1/2 items-center gap-2 sm:h-auto">
<div>
<SidebarTrigger variant="default" />
</div>
<!-- <div>
<Button size="icon">
<AppLogo class="size-8" />
</Button>
</div> -->
<SidebarTrigger variant="default" />
<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"
@@ -234,15 +220,11 @@
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");
@@ -377,13 +359,6 @@
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([

View File

@@ -1,216 +0,0 @@
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 };

View File

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

View File

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

View File

@@ -0,0 +1,418 @@
/**
* 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");
});
});