Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ jobs:
working-directory: web
run: php artisan test

- name: Seed demo data for end-to-end tests
working-directory: web
run: php artisan db:seed --force

- name: Install Playwright browser
working-directory: web
run: npx playwright install --with-deps chromium

- name: Run end-to-end tests
working-directory: web
run: |
php artisan serve --port=8000 > /tmp/serve.log 2>&1 &
for i in $(seq 1 30); do curl -sf http://127.0.0.1:8000/login > /dev/null && break; sleep 1; done
npx playwright test
env:
PLAYWRIGHT_BASE_URL: http://127.0.0.1:8000

extension-build:
name: VS Code Extension Build
runs-on: ubuntu-latest
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ Sessions are written under `~/.claude/projects/<encoded-path>/`. On Windows they

## Sync between machines

1. **Run a sync server.** See [docs/sync-server-setup.md](docs/sync-server-setup.md). Then issue yourself a token:
1. **Run a sync server.** See [docs/sync-server-setup.md](docs/sync-server-setup.md). Then create your account and issue yourself a token:
```bash
php artisan movez:user you@example.com
php artisan movez:token you@example.com
```
2. **Configure each machine.** Prefer environment variables, because command-line arguments are visible to other processes:
Expand Down Expand Up @@ -175,7 +176,7 @@ Install `movez-vscode.vsix` with **Extensions: Install from VSIX…**. It adds a

## Web dashboard

The sync server also serves a dashboard: projects, sessions, and a migration wizard that builds the exact `movez transfer` command for you. It is meant for a private, single-user deployment; the dashboard has no login yet (see Roadmap).
The sync server also serves a dashboard: projects, sessions, and a migration wizard that builds the exact `movez transfer` command for you. Sign in with an account created on the server (`php artisan movez:user you@example.com`); there is no public registration, and each user only sees their own projects and sessions.

---

Expand Down Expand Up @@ -252,7 +253,6 @@ cd electron-app && npm run typecheck # desktop app type check
## Roadmap

- Windsurf support (blocked on its encrypted Cascade storage)
- Login and per-user scoping for the web dashboard
- Server-side migration jobs (Horizon)
- macOS and Linux desktop builds

Expand Down
6 changes: 4 additions & 2 deletions docs/sync-server-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ php artisan migrate --force
### 4. Create a user and an API token

```bash
php artisan tinker --execute="App\Models\User::create(['name' => 'Me', 'email' => 'me@example.com', 'password' => 'change-me']);"
php artisan movez:user me@example.com --name="Me" # prompts for a password (min 12 characters)
php artisan movez:token me@example.com
```

There is no public registration. Every dashboard user is created this way, and each user only sees their own projects, sessions and sync data.

`movez:token` prints the token **once**. Only its SHA-256 hash is stored. Running it again replaces the old token.

### 5. Web server (Nginx)
Expand Down Expand Up @@ -98,7 +100,7 @@ server {
}
```

> The web dashboard has **no login yet**. Restrict it (VPN, IP allow-list or HTTP basic auth on everything except `/api/*`) until authentication lands. The `/api/sync/*` endpoints are protected by API tokens.
> The dashboard requires signing in (`/login`, rate-limited to 5 attempts per minute). The `/api/sync/*` endpoints use API tokens instead of the login session.

---

Expand Down
1 change: 1 addition & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@
Homestead.json
Homestead.yaml
Thumbs.db
/e2e/.auth/
5 changes: 3 additions & 2 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ Deployment: see [docs/sync-server-setup.md](../docs/sync-server-setup.md). Proje
composer install
npm ci
cp .env.example .env && php artisan key:generate
touch database/database.sqlite && php artisan migrate --seed # prints dev API tokens
touch database/database.sqlite && php artisan migrate --seed # demo login demo@movez.test / password, prints dev API tokens
npm run dev # Vite
php artisan serve # http://localhost:8000
```

```bash
php artisan test # Pest feature + unit tests
npm run type-check # vue-tsc
npm run test:e2e # Playwright (starts php artisan serve locally)
npm run test:e2e # Playwright (starts php artisan serve locally; needs the seeded demo user)
php artisan movez:user you@example.com # create a dashboard user (no public registration)
php artisan movez:token you@example.com # issue a sync token
```
47 changes: 47 additions & 0 deletions web/app/Http/Controllers/AuthController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;

class AuthController extends Controller
{
public function create(): Response
{
return Inertia::render('Auth/Login');
}

public function store(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);

if (!Auth::attempt($credentials, $request->boolean('remember'))) {
throw ValidationException::withMessages([
'email' => 'These credentials do not match our records.',
]);
}

$request->session()->regenerate();

return redirect()->intended(route('dashboard'));
}

public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();

$request->session()->invalidate();
$request->session()->regenerateToken();

return redirect()->route('login');
}
}
12 changes: 7 additions & 5 deletions web/app/Http/Controllers/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,22 @@
namespace App\Http\Controllers;

use App\Models\AiSession;
use App\Models\Project;
use App\Models\SyncEvent;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class DashboardController extends Controller
{
public function index(): Response
public function index(Request $request): Response
{
$user = $request->user();

return Inertia::render('Dashboard', [
'stats' => [
'total_sessions' => AiSession::count(),
'total_projects' => Project::count(),
'last_sync' => SyncEvent::latest('created_at')->value('created_at'),
'total_sessions' => AiSession::ownedBy($user)->count(),
'total_projects' => $user->projects()->count(),
'last_sync' => SyncEvent::where('user_id', $user->id)->latest('created_at')->value('created_at'),
],
]);
}
Expand Down
10 changes: 7 additions & 3 deletions web/app/Http/Controllers/MigrationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Models\Project;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;

Expand All @@ -17,12 +18,12 @@ class MigrationController extends Controller
/** Tools the CLI can write to. */
private const TARGET_TOOLS = ['cursor', 'claude-code', 'codex', 'copilot-cli'];

public function wizard(): Response
public function wizard(Request $request): Response
{
return Inertia::render('Migration/Wizard', [
'supportedTools' => self::SOURCE_TOOLS,
'writableTools' => self::TARGET_TOOLS,
'projects' => Project::latest()->get(['id', 'name', 'path']),
'projects' => $request->user()->projects()->latest()->get(['id', 'name', 'path']),
]);
}

Expand All @@ -35,7 +36,10 @@ public function start(Request $request): JsonResponse
$validated = $request->validate([
'from_tool' => ['required', 'string', 'in:' . implode(',', self::SOURCE_TOOLS)],
'to_tool' => ['required', 'string', 'in:' . implode(',', self::TARGET_TOOLS), 'different:from_tool'],
'project_id' => ['nullable', 'integer', 'exists:projects,id'],
'project_id' => [
'nullable', 'integer',
Rule::exists('projects', 'id')->where('user_id', $request->user()->id),
],
'from_path' => ['nullable', 'string', 'max:1024'],
'to_path' => ['nullable', 'string', 'max:1024'],
]);
Expand Down
11 changes: 8 additions & 3 deletions web/app/Http/Controllers/ProjectController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
namespace App\Http\Controllers;

use App\Models\Project;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class ProjectController extends Controller
{
public function index(): Response
public function index(Request $request): Response
{
$projects = Project::withCount('aiSessions')
$projects = $request->user()->projects()
->withCount('aiSessions')
->latest()
->paginate(20);

Expand All @@ -20,8 +22,11 @@ public function index(): Response
]);
}

public function show(Project $project): Response
public function show(Request $request, Project $project): Response
{
// 404 rather than 403 so other users' project ids are not revealed
abort_unless($project->user_id === $request->user()->id, 404);

return Inertia::render('Projects/Show', [
'project' => $project,
'sessions' => $project->aiSessions()->latest()->get(),
Expand Down
6 changes: 5 additions & 1 deletion web/app/Http/Controllers/SessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
namespace App\Http\Controllers;

use App\Models\AiSession;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class SessionController extends Controller
{
public function show(AiSession $session): Response
public function show(Request $request, AiSession $session): Response
{
$session->load('project');

// Sessions are owned through their project; orphaned sessions belong to nobody
abort_unless($session->project?->user_id === $request->user()->id, 404);

return Inertia::render('Sessions/Show', [
'session' => $session,
'sessionData' => $session->getDecodedSessionData(),
Expand Down
4 changes: 3 additions & 1 deletion web/app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ public function share(Request $request): array
{
return [
...parent::share($request),
//
'auth' => [
'user' => fn () => $request->user()?->only(['id', 'name', 'email']),
],
];
}
}
7 changes: 7 additions & 0 deletions web/app/Models/AiSession.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
Expand All @@ -29,6 +30,12 @@ public function project(): BelongsTo
return $this->belongsTo(Project::class);
}

/** Sessions belonging to the user's projects. */
public function scopeOwnedBy(Builder $query, User $user): Builder
{
return $query->whereHas('project', fn (Builder $q) => $q->where('user_id', $user->id));
}

/**
* Decode session_data, falling back to an empty array for anything
* that is not a JSON object/array.
Expand Down
10 changes: 10 additions & 0 deletions web/database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ public function run(): void
{
$users = User::factory(2)->create();

// Known login for local development and the Playwright suite — never in production
if (app()->environment(['local', 'testing'])) {
$users->prepend(User::factory()->create([
'name' => 'Demo',
'email' => 'demo@movez.test',
'password' => 'password',
]));
$this->command?->info('Dashboard login: demo@movez.test / password');
}

foreach ($users as $user) {
// Only the hash is stored, so print the plaintext once for local testing
$token = $user->issueApiToken();
Expand Down
20 changes: 20 additions & 0 deletions web/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
test.use({ storageState: { cookies: [], origins: [] } });

test('guests are sent to the login page', async ({ page }) => {
await page.goto('/projects');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});

test('wrong password shows an error', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('demo@movez.test');
await page.getByLabel('Password').fill('not-the-password');
await page.getByRole('button', { name: 'Sign in' }).click();

await expect(page.getByText('These credentials do not match our records.')).toBeVisible();
});
});
15 changes: 15 additions & 0 deletions web/e2e/global.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { test as setup, expect } from '@playwright/test';

// Signs in once and saves the session for all other specs.
// Uses the demo user created by `php artisan db:seed` in local environments.
const authFile = 'e2e/.auth/user.json';

setup('sign in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.E2E_EMAIL ?? 'demo@movez.test');
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD ?? 'password');
await page.getByRole('button', { name: 'Sign in' }).click();

await expect(page).toHaveURL(/\/$/);
await page.context().storageState({ path: authFile });
});
4 changes: 3 additions & 1 deletion web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ export default defineConfig({
screenshot: 'only-on-failure',
},
projects: [
{ name: 'setup', testMatch: /global\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup'],
},
],
webServer: process.env.CI
Expand Down
Loading
Loading