From 8c68cdfe9f849e17e7f353da3be34d95e7d07113 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 26 Sep 2025 21:50:44 +0200 Subject: [PATCH] Implement Sanctum (#23) Reviewed-on: https://codeberg.org/lvl0/trip-planner/pulls/23 Co-authored-by: myrmidex Co-committed-by: myrmidex --- .gitignore | 1 + .../Http/Controllers/API/AuthController.php | 103 ++++++ backend/app/Http/Middleware/Cors.php | 38 +++ backend/app/Models/User.php | 3 +- backend/bootstrap/app.php | 5 +- backend/composer.json | 2 +- backend/composer.lock | 2 +- backend/config/cors.php | 34 ++ backend/config/sanctum.php | 84 +++++ ...47_create_personal_access_tokens_table.php | 33 ++ backend/routes/api.php | 23 ++ docker-compose.dev.yml | 18 +- docker/backend/Dockerfile.dev | 17 +- docker/frontend/Dockerfile.dev | 6 +- frontend/.env | 2 + frontend/package-lock.json | 280 ++++++++++++++++ frontend/package.json | 1 + frontend/src/App.css | 299 ++++++++++++++++-- frontend/src/App.jsx | 35 +- frontend/src/components/Dashboard.jsx | 49 +++ frontend/src/components/LoginForm.jsx | 120 +++++++ frontend/src/components/RegistrationForm.jsx | 148 +++++++++ frontend/src/components/auth/AuthGuard.jsx | 74 +++++ .../src/components/auth/ProtectedRoute.jsx | 26 ++ frontend/src/contexts/AuthContext.jsx | 87 +++++ frontend/src/utils/api.js | 40 +++ 26 files changed, 1447 insertions(+), 83 deletions(-) create mode 100644 backend/app/Http/Controllers/API/AuthController.php create mode 100644 backend/app/Http/Middleware/Cors.php create mode 100644 backend/config/cors.php create mode 100644 backend/config/sanctum.php create mode 100644 backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php create mode 100644 backend/routes/api.php create mode 100644 frontend/.env create mode 100644 frontend/src/components/Dashboard.jsx create mode 100644 frontend/src/components/LoginForm.jsx create mode 100644 frontend/src/components/RegistrationForm.jsx create mode 100644 frontend/src/components/auth/AuthGuard.jsx create mode 100644 frontend/src/components/auth/ProtectedRoute.jsx create mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/utils/api.js diff --git a/.gitignore b/.gitignore index a09c56d..70ea49a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /.idea +/docker/data \ No newline at end of file diff --git a/backend/app/Http/Controllers/API/AuthController.php b/backend/app/Http/Controllers/API/AuthController.php new file mode 100644 index 0000000..455393a --- /dev/null +++ b/backend/app/Http/Controllers/API/AuthController.php @@ -0,0 +1,103 @@ +all(), [ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation errors', + 'data' => $validator->errors() + ], 422); + } + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + $token = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => 'User registered successfully', + 'data' => [ + 'user' => $user, + 'access_token' => $token, + 'token_type' => 'Bearer' + ] + ], 201); + } + + public function login(Request $request) + { + $validator = Validator::make($request->all(), [ + 'email' => 'required|email', + 'password' => 'required', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation errors', + 'data' => $validator->errors() + ], 422); + } + + $user = User::where('email', $request->email)->first(); + + if (!$user || !Hash::check($request->password, $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['The provided credentials are incorrect.'], + ]); + } + + $token = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => 'Login successful', + 'data' => [ + 'user' => $user, + 'access_token' => $token, + 'token_type' => 'Bearer' + ] + ]); + } + + public function profile(Request $request) + { + return response()->json([ + 'success' => true, + 'message' => 'Profile retrieved successfully', + 'data' => $request->user() + ]); + } + + public function logout(Request $request) + { + $request->user()->currentAccessToken()->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Logout successful' + ]); + } +} \ No newline at end of file diff --git a/backend/app/Http/Middleware/Cors.php b/backend/app/Http/Middleware/Cors.php new file mode 100644 index 0000000..48e3ece --- /dev/null +++ b/backend/app/Http/Middleware/Cors.php @@ -0,0 +1,38 @@ +headers->get('Origin'); + $allowedOrigin = env('FRONTEND_URL', 'http://localhost:5173'); + + // Only set CORS headers if the origin matches our frontend + if ($origin === $allowedOrigin) { + $response->headers->set('Access-Control-Allow-Origin', $origin); + $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, Accept, X-XSRF-TOKEN'); + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + + // Handle preflight OPTIONS requests + if ($request->getMethod() === 'OPTIONS') { + $response->setStatusCode(200); + } + + return $response; + } +} \ No newline at end of file diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 749c7b7..91135d7 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index c183276..3e248e3 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -7,11 +7,14 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->api(prepend: [ + \App\Http\Middleware\Cors::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/backend/composer.json b/backend/composer.json index 34284d9..40d10fc 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0", - "laravel/sanctum": "^4.2", + "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1" }, "require-dev": { diff --git a/backend/composer.lock b/backend/composer.lock index a4d9098..7077164 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8f387a0734f3bf879214e4aa2fca6e2f", + "content-hash": "d3c16cb86c42230c6c023d9a5d9bcf42", "packages": [ { "name": "brick/math", diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..ce5a23f --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,34 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; \ No newline at end of file diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php b/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..946570d --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,23 @@ +where('any', '.*'); + +// Public routes +Route::post('/register', [AuthController::class, 'register']); +Route::post('/login', [AuthController::class, 'login']); + +// Protected routes +Route::middleware('auth:sanctum')->group(function () { + Route::get('/user', function (Request $request) { + return $request->user(); + }); + Route::get('/profile', [AuthController::class, 'profile']); + Route::post('/logout', [AuthController::class, 'logout']); +}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fb6c208..7e4f380 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,10 +9,11 @@ services: ports: - "5173:5173" volumes: - - ./frontend:/app:z - - node_modules:/app/node_modules:Z + - ./frontend:/app:Z + - node_modules:/app/node_modules environment: - NODE_ENV=development + privileged: true networks: - trip-planner-network @@ -24,13 +25,14 @@ services: ports: - "8000:8000" volumes: - - ./backend:/var/www/html:z - - vendor:/var/www/html/vendor:Z + - ./backend:/var/www/html:Z + - vendor:/var/www/html/vendor env_file: - .env.local depends_on: - database - redis + privileged: true networks: - trip-planner-network @@ -45,7 +47,8 @@ services: MYSQL_USER: ${DB_USERNAME:-trip_user} MYSQL_PASSWORD: ${DB_PASSWORD:-secret} volumes: - - db-data:/var/lib/mysql:Z + - ./docker/data/mysql-data:/var/lib/mysql:Z + privileged: true networks: - trip-planner-network @@ -55,7 +58,8 @@ services: ports: - "6379:6379" volumes: - - redis-data:/data:Z + - redis-data:/data + privileged: true networks: - trip-planner-network @@ -76,4 +80,4 @@ volumes: db-data: redis-data: node_modules: - vendor: \ No newline at end of file + vendor: diff --git a/docker/backend/Dockerfile.dev b/docker/backend/Dockerfile.dev index ce59d40..4891c28 100644 --- a/docker/backend/Dockerfile.dev +++ b/docker/backend/Dockerfile.dev @@ -21,20 +21,11 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer WORKDIR /var/www/html -# Create developer user with UID 1000 (same as host user) -RUN adduser -u 1000 -s /bin/sh -D developer +# Create storage and bootstrap/cache directories with proper permissions +RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache && \ + chmod -R 777 storage bootstrap/cache -# Create storage and bootstrap/cache directories -RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache - -# Change ownership to developer user -RUN chown -R developer:developer /var/www/html - -# Set proper permissions for Laravel directories -RUN chmod -R 775 storage bootstrap/cache - -# Switch to developer user -USER developer +# Run as root to avoid permission issues with volume mounts # Expose port 8000 for artisan serve EXPOSE 8000 diff --git a/docker/frontend/Dockerfile.dev b/docker/frontend/Dockerfile.dev index 4f242bc..c3a9e47 100644 --- a/docker/frontend/Dockerfile.dev +++ b/docker/frontend/Dockerfile.dev @@ -5,11 +5,7 @@ RUN npm install -g vite WORKDIR /app -# Change ownership of /app to node user (UID 1000) -RUN chown -R node:node /app - -# Switch to node user (UID 1000, same as host user) -USER node +# Run as root to avoid permission issues with volume mounts # Expose Vite dev server port EXPOSE 5173 diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..9c4b96e --- /dev/null +++ b/frontend/.env @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:8000 +VITE_API_BASE_URL=http://localhost:8000/api \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d1365ea..6d9cf70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -1481,6 +1482,23 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1543,6 +1561,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1611,6 +1642,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1672,6 +1715,29 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.224", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.224.tgz", @@ -1679,6 +1745,51 @@ "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2012,6 +2123,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2027,6 +2174,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2037,6 +2193,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2063,6 +2256,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2073,6 +2278,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2264,6 +2508,36 @@ "yallist": "^3.0.2" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2459,6 +2733,12 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 82deee1..e82bc18 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355d..1532ab9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,42 +1,285 @@ -#root { - max-width: 1280px; +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; +} + +.App { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + width: 100vw; + box-sizing: border-box; +} + +.App-header { + text-align: center; + margin-bottom: 2rem; +} + +.registration-form, +.login-form { + background: #f8f9fa; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 400px; + margin: 0 auto; +} + +.registration-form h2, +.login-form h2 { + text-align: center; + margin-bottom: 1.5rem; + color: #333; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + box-sizing: border-box; +} + +.form-group input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); +} + +.form-group input.error { + border-color: #dc3545; +} + +.error-message { + display: block; + color: #dc3545; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.alert { + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border-radius: 4px; +} + +.alert-success { + color: #155724; + background-color: #d4edda; + border: 1px solid #c3e6cb; +} + +.alert-error { + color: #721c24; + background-color: #f8d7da; + border: 1px solid #f5c6cb; +} + +button[type="submit"] { + width: 100%; + padding: 0.75rem; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +button[type="submit"]:hover:not(:disabled) { + background-color: #0056b3; +} + +button[type="submit"]:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +/* Auth Guard Styles */ +.auth-container { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; +} + +.auth-content { + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + align-items: center; +} + +.auth-toggle { + display: flex; + margin-bottom: 2rem; + border-radius: 8px; + overflow: hidden; + border: 1px solid #ddd; +} + +.auth-toggle button { + flex: 1; + padding: 0.75rem; + background: #f8f9fa; + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.auth-toggle button.active { + background: #007bff; + color: white; +} + +.auth-toggle button:hover:not(.active) { + background: #e9ecef; +} + +.auth-switch { + text-align: center; + margin-top: 1rem; +} + +.link-button { + background: none; + border: none; + color: #007bff; + cursor: pointer; + text-decoration: underline; + padding: 0; + font-size: inherit; +} + +.link-button:hover { + color: #0056b3; +} + +/* Dashboard Styles */ +.dashboard { + max-width: 1200px; margin: 0 auto; padding: 2rem; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #eee; +} + +.dashboard-header h1 { + margin: 0; + color: #333; +} + +.user-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.logout-btn { + padding: 0.5rem 1rem; + background: #dc3545; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.logout-btn:hover { + background: #c82333; +} + +.welcome-section { + text-align: center; + margin-bottom: 3rem; +} + +.welcome-section h2 { + margin-bottom: 0.5rem; + color: #333; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; + margin-top: 2rem; +} + +.feature-card { + background: #f8f9fa; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); text-align: center; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); +.feature-card h3 { + margin-bottom: 1rem; + color: #007bff; } -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +.feature-card p { + color: #666; + line-height: 1.6; } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } +/* Loading Styles */ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; } -.card { - padding: 2em; +.loading-spinner { + font-size: 1.2rem; + color: #666; } -.read-the-docs { - color: #888; +.unauthorized-container { + text-align: center; + padding: 3rem; + background: #f8f9fa; + border-radius: 8px; + margin: 2rem 0; +} + +.unauthorized-container h2 { + color: #dc3545; + margin-bottom: 1rem; +} + +.unauthorized-container p { + color: #666; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f67355a..3e17bf1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,34 +1,17 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' +import { AuthProvider } from './contexts/AuthContext' +import AuthGuard from './components/auth/AuthGuard' +import Dashboard from './components/Dashboard' import './App.css' function App() { - const [count, setCount] = useState(0) - return ( - <> -
- - Vite logo - - - React logo - + +
+ + +
-

Vite + React

-
- -

- Edit src/App.jsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- +
) } diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx new file mode 100644 index 0000000..415914a --- /dev/null +++ b/frontend/src/components/Dashboard.jsx @@ -0,0 +1,49 @@ +import { useAuth } from '../contexts/AuthContext'; + +const Dashboard = () => { + const { user, logout } = useAuth(); + + const handleLogout = async () => { + await logout(); + }; + + return ( +
+
+

Trip Planner Dashboard

+
+ Welcome, {user?.name}! + +
+
+ +
+
+

Welcome to Your Trip Planner

+

Start planning your next adventure!

+
+ +
+
+

Plan Trips

+

Create and organize your travel itineraries

+
+ +
+

Save Destinations

+

Keep track of places you want to visit

+
+ +
+

Share Plans

+

Collaborate with friends and family

+
+
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx new file mode 100644 index 0000000..0b55853 --- /dev/null +++ b/frontend/src/components/LoginForm.jsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import api from '../utils/api'; + +const LoginForm = ({ onLoginSuccess }) => { + const [formData, setFormData] = useState({ + email: '', + password: '' + }); + + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prevState => ({ + ...prevState, + [name]: value + })); + + if (errors[name]) { + setErrors(prevErrors => ({ + ...prevErrors, + [name]: '' + })); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + setErrors({}); + + try { + const response = await api.post('/login', formData); + + if (response.data.success) { + localStorage.setItem('token', response.data.data.access_token); + localStorage.setItem('user', JSON.stringify(response.data.data.user)); + + if (onLoginSuccess) { + onLoginSuccess(response.data.data); + } + } + } catch (error) { + if (error.response && error.response.status === 422) { + // Validation errors - check both .data and .errors structure + const validationErrors = error.response.data.errors || error.response.data.data || {}; + + // If it's credential error, show as general message + if (validationErrors.email && validationErrors.email[0] === 'The provided credentials are incorrect.') { + setErrors({ general: 'Invalid email or password. Please try again.' }); + } else { + setErrors(validationErrors); + } + } else if (error.response && error.response.status === 401) { + // Unauthorized - wrong credentials + setErrors({ general: 'Invalid email or password. Please try again.' }); + } else if (error.response && error.response.data.message) { + // Other server errors + setErrors({ general: error.response.data.message }); + } else if (error.request) { + // Network error + setErrors({ general: 'Unable to connect to server. Please check your connection.' }); + } else { + // Unknown error + setErrors({ general: 'Unknown error occurred. Please try again.' }); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Login

+ + {errors.general && ( +
+ {errors.general} +
+ )} + +
+
+ + + {errors.email && {errors.email[0]}} +
+ +
+ + + {errors.password && {errors.password[0]}} +
+ + +
+
+ ); +}; + +export default LoginForm; \ No newline at end of file diff --git a/frontend/src/components/RegistrationForm.jsx b/frontend/src/components/RegistrationForm.jsx new file mode 100644 index 0000000..35ba987 --- /dev/null +++ b/frontend/src/components/RegistrationForm.jsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import api from '../utils/api'; + +const RegistrationForm = ({ onRegistrationSuccess }) => { + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + password_confirmation: '' + }); + + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prevState => ({ + ...prevState, + [name]: value + })); + + // Clear specific field error when user starts typing + if (errors[name]) { + setErrors(prevErrors => ({ + ...prevErrors, + [name]: '' + })); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + setErrors({}); + setSuccessMessage(''); + + try { + const response = await api.post('/register', formData); + + if (response.data.success) { + setSuccessMessage('Registration successful! You are now logged in.'); + setFormData({ + name: '', + email: '', + password: '', + password_confirmation: '' + }); + + if (onRegistrationSuccess) { + onRegistrationSuccess(response.data.data); + } + } + } catch (error) { + if (error.response && error.response.status === 422) { + setErrors(error.response.data.data || {}); + } else { + setErrors({ general: 'Registration failed. Please try again.' }); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Register

+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {errors.general && ( +
+ {errors.general} +
+ )} + +
+
+ + + {errors.name && {errors.name[0]}} +
+ +
+ + + {errors.email && {errors.email[0]}} +
+ +
+ + + {errors.password && {errors.password[0]}} +
+ +
+ + + {errors.password_confirmation && {errors.password_confirmation[0]}} +
+ + +
+
+ ); +}; + +export default RegistrationForm; \ No newline at end of file diff --git a/frontend/src/components/auth/AuthGuard.jsx b/frontend/src/components/auth/AuthGuard.jsx new file mode 100644 index 0000000..a03ab65 --- /dev/null +++ b/frontend/src/components/auth/AuthGuard.jsx @@ -0,0 +1,74 @@ +import { useAuth } from '../../contexts/AuthContext'; +import LoginForm from '../LoginForm'; +import RegistrationForm from '../RegistrationForm'; +import { useState } from 'react'; + +const AuthGuard = ({ children }) => { + const { isAuthenticated, isLoading, login, register } = useAuth(); + const [showLogin, setShowLogin] = useState(true); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+
+
+ + +
+ + {showLogin ? ( + + ) : ( + + )} + +
+ {showLogin ? ( +

+ Don't have an account?{' '} + +

+ ) : ( +

+ Already have an account?{' '} + +

+ )} +
+
+
+ ); + } + + return children; +}; + +export default AuthGuard; \ No newline at end of file diff --git a/frontend/src/components/auth/ProtectedRoute.jsx b/frontend/src/components/auth/ProtectedRoute.jsx new file mode 100644 index 0000000..75132f0 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.jsx @@ -0,0 +1,26 @@ +import { useAuth } from '../../contexts/AuthContext'; + +const ProtectedRoute = ({ children }) => { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+

Access Denied

+

Please log in to access this page.

+
+ ); + } + + return children; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..8952674 --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,87 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import api from '../utils/api'; + +const AuthContext = createContext(); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + useEffect(() => { + const initializeAuth = () => { + const token = localStorage.getItem('token'); + const userData = localStorage.getItem('user'); + + if (token && userData) { + try { + const parsedUser = JSON.parse(userData); + setUser(parsedUser); + setIsAuthenticated(true); + + // Token will be automatically added by API interceptor + } catch (error) { + console.error('Error parsing user data:', error); + logout(); + } + } + setIsLoading(false); + }; + + initializeAuth(); + }, []); + + const login = (userData) => { + const { user: userInfo, access_token } = userData; + + setUser(userInfo); + setIsAuthenticated(true); + + localStorage.setItem('token', access_token); + localStorage.setItem('user', JSON.stringify(userInfo)); + }; + + const logout = async () => { + try { + const token = localStorage.getItem('token'); + if (token) { + await api.post('/logout'); + } + } catch (error) { + console.error('Logout error:', error); + } finally { + setUser(null); + setIsAuthenticated(false); + + localStorage.removeItem('token'); + localStorage.removeItem('user'); + } + }; + + const register = (userData) => { + login(userData); + }; + + const value = { + user, + isLoading, + isAuthenticated, + login, + logout, + register + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 0000000..9a6148f --- /dev/null +++ b/frontend/src/utils/api.js @@ -0,0 +1,40 @@ +import axios from 'axios'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api'; + +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } +}); + +// Request interceptor to add auth token +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor to handle token expiration +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/'; + } + return Promise.reject(error); + } +); + +export default api; \ No newline at end of file