Implement Sanctum #23

Merged
myrmidex merged 1 commit from refs/pull/23/head into release/v0.1.0 2025-09-26 21:50:44 +02:00
26 changed files with 1447 additions and 83 deletions
Showing only changes of commit 15f833b42d - Show all commits

1
.gitignore vendored
View file

@ -1 +1,2 @@
/.idea
/docker/data

View file

@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request)
{
$validator = Validator::make($request->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'
]);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class Cors
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$origin = $request->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;
}
}

View file

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

View file

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

View file

@ -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": {

2
backend/composer.lock generated
View file

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

34
backend/config/cors.php Normal file
View file

@ -0,0 +1,34 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['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,
];

View file

@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => 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,
],
];

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->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');
}
};

23
backend/routes/api.php Normal file
View file

@ -0,0 +1,23 @@
<?php
use App\Http\Controllers\API\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
// Handle preflight OPTIONS requests
Route::options('{any}', function() {
return response('', 200);
})->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']);
});

View file

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

View file

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

View file

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

2
frontend/.env Normal file
View file

@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8000
VITE_API_BASE_URL=http://localhost:8000/api

View file

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

View file

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},

View file

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

View file

@ -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 (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
<AuthProvider>
<div className="App">
<AuthGuard>
<Dashboard />
</AuthGuard>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
</AuthProvider>
)
}

View file

@ -0,0 +1,49 @@
import { useAuth } from '../contexts/AuthContext';
const Dashboard = () => {
const { user, logout } = useAuth();
const handleLogout = async () => {
await logout();
};
return (
<div className="dashboard">
<header className="dashboard-header">
<h1>Trip Planner Dashboard</h1>
<div className="user-info">
<span>Welcome, {user?.name}!</span>
<button onClick={handleLogout} className="logout-btn">
Logout
</button>
</div>
</header>
<main className="dashboard-content">
<div className="welcome-section">
<h2>Welcome to Your Trip Planner</h2>
<p>Start planning your next adventure!</p>
</div>
<div className="features-grid">
<div className="feature-card">
<h3>Plan Trips</h3>
<p>Create and organize your travel itineraries</p>
</div>
<div className="feature-card">
<h3>Save Destinations</h3>
<p>Keep track of places you want to visit</p>
</div>
<div className="feature-card">
<h3>Share Plans</h3>
<p>Collaborate with friends and family</p>
</div>
</div>
</main>
</div>
);
};
export default Dashboard;

View file

@ -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 (
<div className="login-form">
<h2>Login</h2>
{errors.general && (
<div className="alert alert-error">
{errors.general}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className={errors.email ? 'error' : ''}
/>
{errors.email && <span className="error-message">{errors.email[0]}</span>}
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
className={errors.password ? 'error' : ''}
/>
{errors.password && <span className="error-message">{errors.password[0]}</span>}
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
);
};
export default LoginForm;

View file

@ -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 (
<div className="registration-form">
<h2>Register</h2>
{successMessage && (
<div className="alert alert-success">
{successMessage}
</div>
)}
{errors.general && (
<div className="alert alert-error">
{errors.general}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name:</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
className={errors.name ? 'error' : ''}
/>
{errors.name && <span className="error-message">{errors.name[0]}</span>}
</div>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className={errors.email ? 'error' : ''}
/>
{errors.email && <span className="error-message">{errors.email[0]}</span>}
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
minLength="8"
className={errors.password ? 'error' : ''}
/>
{errors.password && <span className="error-message">{errors.password[0]}</span>}
</div>
<div className="form-group">
<label htmlFor="password_confirmation">Confirm Password:</label>
<input
type="password"
id="password_confirmation"
name="password_confirmation"
value={formData.password_confirmation}
onChange={handleChange}
required
minLength="8"
className={errors.password_confirmation ? 'error' : ''}
/>
{errors.password_confirmation && <span className="error-message">{errors.password_confirmation[0]}</span>}
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Registering...' : 'Register'}
</button>
</form>
</div>
);
};
export default RegistrationForm;

View file

@ -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 (
<div className="loading-container">
<div className="loading-spinner">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="auth-container">
<div className="auth-content">
<div className="auth-toggle">
<button
className={showLogin ? 'active' : ''}
onClick={() => setShowLogin(true)}
>
Login
</button>
<button
className={!showLogin ? 'active' : ''}
onClick={() => setShowLogin(false)}
>
Register
</button>
</div>
{showLogin ? (
<LoginForm onLoginSuccess={login} />
) : (
<RegistrationForm onRegistrationSuccess={register} />
)}
<div className="auth-switch">
{showLogin ? (
<p>
Don't have an account?{' '}
<button
className="link-button"
onClick={() => setShowLogin(false)}
>
Sign up here
</button>
</p>
) : (
<p>
Already have an account?{' '}
<button
className="link-button"
onClick={() => setShowLogin(true)}
>
Login here
</button>
</p>
)}
</div>
</div>
</div>
);
}
return children;
};
export default AuthGuard;

View file

@ -0,0 +1,26 @@
import { useAuth } from '../../contexts/AuthContext';
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="loading-container">
<div className="loading-spinner">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="unauthorized-container">
<h2>Access Denied</h2>
<p>Please log in to access this page.</p>
</div>
);
}
return children;
};
export default ProtectedRoute;

View file

@ -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 (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

40
frontend/src/utils/api.js Normal file
View file

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