feat: Reinitialize frontend with SvelteKit and TypeScript

- Delete old Vite+Svelte frontend
- Initialize new SvelteKit project with TypeScript
- Configure Tailwind CSS v4 + DaisyUI
- Implement JWT authentication with auto-refresh
- Create login page with form validation (Zod)
- Add protected route guards
- Update Docker configuration for single-stage build
- Add E2E tests with Playwright (6/11 passing)
- Fix Svelte 5 reactivity with $state() runes

Known issues:
- 5 E2E tests failing (timing/async issues)
- Token refresh implementation needs debugging
- Validation error display timing
This commit is contained in:
2026-02-17 16:19:59 -05:00
parent 54df6018f5
commit f935754df4
120 changed files with 21772 additions and 90 deletions

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Validator;
class AuthController extends Controller
{
public function login(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'email' => 'required|string|email',
'password' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'errors' => $validator->errors(),
], 422);
}
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json([
'message' => 'Invalid credentials',
], 401);
}
if (! $user->active) {
return response()->json([
'message' => 'Account is inactive',
], 403);
}
$accessToken = $this->generateAccessToken($user);
$refreshToken = $this->generateRefreshToken($user);
return response()->json([
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'bearer',
'expires_in' => 3600,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
],
]);
}
public function refresh(Request $request): JsonResponse
{
$refreshToken = $request->input('refresh_token');
$userId = $this->getUserIdFromRefreshToken($refreshToken);
if (! $userId) {
return response()->json([
'message' => 'Invalid or expired refresh token',
], 401);
}
$user = User::find($userId);
if (! $user) {
return response()->json([
'message' => 'Invalid or expired refresh token',
], 401);
}
$this->invalidateRefreshToken($refreshToken, $userId);
$accessToken = $this->generateAccessToken($user);
$newRefreshToken = $this->generateRefreshToken($user);
return response()->json([
'access_token' => $accessToken,
'refresh_token' => $newRefreshToken,
'token_type' => 'bearer',
'expires_in' => 3600,
]);
}
public function logout(Request $request): JsonResponse
{
$user = $request->user();
$refreshToken = $request->input('refresh_token');
if ($refreshToken) {
$this->invalidateRefreshToken($refreshToken, $user->id);
}
return response()->json([
'message' => 'Logged out successfully',
]);
}
protected function generateAccessToken(User $user): string
{
$payload = [
'iss' => config('app.url', 'headroom'),
'sub' => $user->id,
'iat' => time(),
'exp' => time() + 3600,
'role' => $user->role,
'permissions' => $this->getPermissions($user->role),
'jti' => uniqid('token_', true),
];
return $this->encodeJWT($payload);
}
protected function generateRefreshToken(User $user): string
{
$token = bin2hex(random_bytes(32));
// Store with token as the key part for easy lookup
$key = "refresh_token:{$token}";
Redis::setex($key, 604800, $user->id);
return $token;
}
protected function getUserIdFromRefreshToken(string $token): ?string
{
return Redis::get("refresh_token:{$token}") ?: null;
}
protected function invalidateRefreshToken(string $token, string $userId): void
{
Redis::del("refresh_token:{$token}");
}
protected function getPermissions(string $role): array
{
return match ($role) {
'superuser' => [
'manage_users',
'manage_team_members',
'manage_projects',
'manage_allocations',
'manage_actuals',
'view_reports',
'configure_system',
'view_audit_logs',
],
'manager' => [
'manage_projects',
'manage_allocations',
'manage_actuals',
'view_reports',
'manage_team_members',
],
'developer' => [
'manage_actuals',
'view_own_allocations',
'view_own_actuals',
'log_hours',
],
'top_brass' => [
'view_reports',
'view_allocations',
'view_actuals',
'view_capacity',
],
default => [],
};
}
protected function encodeJWT(array $payload): string
{
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$header = base64_encode($header);
$header = str_replace(['+', '/', '='], ['-', '_', ''], $header);
$payload = json_encode($payload);
$payload = base64_encode($payload);
$payload = str_replace(['+', '/', '='], ['-', '_', ''], $payload);
$signature = hash_hmac('sha256', $header . '.' . $payload, config('app.key'), true);
$signature = base64_encode($signature);
$signature = str_replace(['+', '/', '='], ['-', '_', ''], $signature);
return $header . '.' . $payload . '.' . $signature;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class JwtAuth
{
public function handle(Request $request, Closure $next): Response
{
$token = $this->extractToken($request);
if (! $token) {
return response()->json([
'message' => 'Authentication required',
], 401);
}
$payload = $this->decodeJWT($token);
if (! $payload) {
return response()->json([
'message' => 'Invalid token',
], 401);
}
if ($payload->exp < time()) {
return response()->json([
'message' => 'Token expired',
], 401);
}
$user = \App\Models\User::find($payload->sub);
if (! $user) {
return response()->json([
'message' => 'User not found',
], 401);
}
auth()->setUser($user);
$request->setUserResolver(fn () => $user);
return $next($request);
}
protected function extractToken(Request $request): ?string
{
$header = $request->header('Authorization');
if (! $header || ! str_starts_with($header, 'Bearer ')) {
return null;
}
return substr($header, 7);
}
protected function decodeJWT(string $token): ?object
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
list($header, $payload, $signature) = $parts;
$expectedSignature = hash_hmac('sha256', $header . '.' . $payload, config('app.key'), true);
$expectedSignature = base64_encode($expectedSignature);
$expectedSignature = str_replace(['+', '/', '='], ['-', '_', ''], $expectedSignature);
if (! hash_equals($expectedSignature, $signature)) {
return null;
}
$payload = base64_decode(str_replace(['-', '_'], ['+', '/'], $payload));
return json_decode($payload);
}
}