landing page dan modal login tapi masih banyak yang error
This commit is contained in:
parent
d4ecbe5b45
commit
8c5ef3e1cd
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Resources\ContactInfoResource;
|
use App\Http\Resources\ContactInfoResource;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\HeroSection;
|
use App\Models\HeroSection;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\LoginRequest;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle user login
|
||||||
|
*/
|
||||||
|
public function login(LoginRequest $request)
|
||||||
|
{
|
||||||
|
$credentials = $request->validated();
|
||||||
|
|
||||||
|
// Auto-assign role based on email domain
|
||||||
|
$email = $credentials['email'];
|
||||||
|
$role = $this->assignRoleByEmail($email);
|
||||||
|
|
||||||
|
// Find user by email
|
||||||
|
$user = User::where('email', $email)->first();
|
||||||
|
|
||||||
|
if (!$user || !Hash::check($credentials['password'], $user->password)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Email atau password salah'
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user role if changed
|
||||||
|
if ($user->role !== $role) {
|
||||||
|
$user->role = $role;
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token
|
||||||
|
$token = $user->createToken('auth-token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Login berhasil',
|
||||||
|
'token' => $token,
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'role' => $user->role,
|
||||||
|
'nim' => $user->nim,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle user logout
|
||||||
|
*/
|
||||||
|
public function logout(Request $request)
|
||||||
|
{
|
||||||
|
$request->user()->currentAccessToken()->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Logout berhasil'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user
|
||||||
|
*/
|
||||||
|
public function user(Request $request)
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'user' => [
|
||||||
|
'id' => $request->user()->id,
|
||||||
|
'name' => $request->user()->name,
|
||||||
|
'email' => $request->user()->email,
|
||||||
|
'role' => $request->user()->role,
|
||||||
|
'nim' => $request->user()->nim,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-assign role based on email domain
|
||||||
|
*/
|
||||||
|
private function assignRoleByEmail(string $email): string
|
||||||
|
{
|
||||||
|
// Student email pattern: nim@student.polije.ac.id
|
||||||
|
if (str_contains($email, 'student.polije.ac.id')) {
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff email pattern: name@polije.ac.id
|
||||||
|
if (str_contains($email, 'polije.ac.id')) {
|
||||||
|
return 'konselor'; // Default to konselor for staff
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RoleMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next, string $role): Response
|
||||||
|
{
|
||||||
|
if (!auth()->check()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Unauthorized'
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
// Check if user has the required role
|
||||||
|
if ($user->role !== $role) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Forbidden - Insufficient permissions'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||||
|
use Illuminate\Contracts\Validation\Validator;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required|min:8',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the error messages for the defined validation rules.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email.required' => 'Email wajib diisi',
|
||||||
|
'email.email' => 'Format email tidak valid',
|
||||||
|
'password.required' => 'Password wajib diisi',
|
||||||
|
'password.min' => 'Password minimal 8 karakter',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the validator instance.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Validation\Validator $validator
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function ($validator) {
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
// Custom validation: email must contain polije.ac.id
|
||||||
|
if ($email && !str_contains($email, 'polije.ac.id')) {
|
||||||
|
$validator->errors()->add('email', 'Gunakan email resmi Polije (@polije.ac.id atau @student.polije.ac.id)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom attributes for validator errors.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function attributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => 'Email',
|
||||||
|
'password' => 'Password',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,12 @@
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable, HasApiTokens;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
|
|
@ -21,6 +22,8 @@ class User extends Authenticatable
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'role',
|
||||||
|
'nim',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,4 +48,28 @@ protected function casts(): array
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is student
|
||||||
|
*/
|
||||||
|
public function isStudent(): bool
|
||||||
|
{
|
||||||
|
return $this->role === 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is konselor
|
||||||
|
*/
|
||||||
|
public function isKonselor(): bool
|
||||||
|
{
|
||||||
|
return $this->role === 'konselor';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is operator
|
||||||
|
*/
|
||||||
|
public function isOperator(): bool
|
||||||
|
{
|
||||||
|
return $this->role === 'operator';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^2.10.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "c514d8f7b9fc5970bdd94287905ef584",
|
"content-hash": "ad91ab9bde70e3576f2b64dd11542bc4",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
|
|
@ -1333,6 +1333,69 @@
|
||||||
},
|
},
|
||||||
"time": "2026-02-06T12:17:10+00:00"
|
"time": "2026-02-06T12:17:10+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/sanctum",
|
||||||
|
"version": "v4.3.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/sanctum.git",
|
||||||
|
"reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
|
||||||
|
"reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/database": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||||
|
"php": "^8.2",
|
||||||
|
"symfony/console": "^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
||||||
|
"phpstan/phpstan": "^1.10"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Sanctum\\SanctumServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Sanctum\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
|
||||||
|
"keywords": [
|
||||||
|
"auth",
|
||||||
|
"laravel",
|
||||||
|
"sanctum"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/sanctum/issues",
|
||||||
|
"source": "https://github.com/laravel/sanctum"
|
||||||
|
},
|
||||||
|
"time": "2026-02-07T17:19:31+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/serializable-closure",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.9",
|
"version": "v2.0.9",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
|
|
||||||
'allowed_methods' => ['*'],
|
'allowed_methods' => ['*'],
|
||||||
|
|
||||||
'allowed_origins' => ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:5175'],
|
'allowed_origins' => ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:5175', 'http://localhost:5176', 'http://127.0.0.1:5176'],
|
||||||
|
|
||||||
'allowed_origins_patterns' => [],
|
'allowed_origins_patterns' => [],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -17,24 +17,11 @@ public function up(): void
|
||||||
$table->string('email')->unique();
|
$table->string('email')->unique();
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
$table->string('password');
|
$table->string('password');
|
||||||
|
$table->enum('role', ['user', 'konselor', 'operator'])->default('user');
|
||||||
|
$table->string('nim')->nullable(); // For student users
|
||||||
$table->rememberToken();
|
$table->rememberToken();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|
||||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
|
||||||
$table->string('email')->primary();
|
|
||||||
$table->string('token');
|
|
||||||
$table->timestamp('created_at')->nullable();
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('sessions', function (Blueprint $table) {
|
|
||||||
$table->string('id')->primary();
|
|
||||||
$table->foreignId('user_id')->nullable()->index();
|
|
||||||
$table->string('ip_address', 45)->nullable();
|
|
||||||
$table->text('user_agent')->nullable();
|
|
||||||
$table->longText('payload');
|
|
||||||
$table->integer('last_activity')->index();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -43,7 +30,5 @@ public function up(): void
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::dropIfExists('users');
|
Schema::dropIfExists('users');
|
||||||
Schema::dropIfExists('password_reset_tokens');
|
|
||||||
Schema::dropIfExists('sessions');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -79,10 +79,33 @@ public function run(): void
|
||||||
Article::create($article);
|
Article::create($article);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create test user
|
// Create test users with specific roles
|
||||||
User::factory()->create([
|
$users = [
|
||||||
'name' => 'Admin PolijeCare',
|
[
|
||||||
'email' => 'admin@polije.ac.id',
|
'name' => 'Ahmad Mahasiswa',
|
||||||
]);
|
'email' => '2021001@student.polije.ac.id',
|
||||||
|
'password' => Hash::make('password123'),
|
||||||
|
'role' => 'user',
|
||||||
|
'nim' => '2021001',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Siti Konselor',
|
||||||
|
'email' => 'siti@polije.ac.id',
|
||||||
|
'password' => Hash::make('password123'),
|
||||||
|
'role' => 'konselor',
|
||||||
|
'nim' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Budi Operator',
|
||||||
|
'email' => 'budi@polije.ac.id',
|
||||||
|
'password' => Hash::make('password123'),
|
||||||
|
'role' => 'operator',
|
||||||
|
'nim' => null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
User::create($user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class UserSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Create test users
|
||||||
|
$users = [
|
||||||
|
[
|
||||||
|
'name' => 'Ahmad Mahasiswa',
|
||||||
|
'email' => '2021001@student.polije.ac.id',
|
||||||
|
'password' => Hash::make('password123'),
|
||||||
|
'role' => 'user',
|
||||||
|
'nim' => '2021001',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Siti Konselor',
|
||||||
|
'email' => 'siti@polije.ac.id',
|
||||||
|
'password' => Hash::make('password123'),
|
||||||
|
'role' => 'konselor',
|
||||||
|
'nim' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Budi Operator',
|
||||||
|
'email' => 'budi@polije.ac.id',
|
||||||
|
'password' => Hash::make('password123'),
|
||||||
|
'role' => 'operator',
|
||||||
|
'nim' => null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
User::create($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Http\Controllers\Api\ArticleController;
|
use App\Http\Controllers\API\ArticleController;
|
||||||
use App\Http\Controllers\Api\ContactController;
|
use App\Http\Controllers\API\ContactController;
|
||||||
use App\Http\Controllers\Api\HeroController;
|
use App\Http\Controllers\API\HeroController;
|
||||||
|
use App\Http\Controllers\AuthController;
|
||||||
|
use App\Http\Middleware\RoleMiddleware;
|
||||||
|
|
||||||
Route::get('/test', function () {
|
Route::get('/test', function () {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -21,3 +23,54 @@
|
||||||
|
|
||||||
// Hero Section
|
// Hero Section
|
||||||
Route::get('/hero', [HeroController::class, 'index']);
|
Route::get('/hero', [HeroController::class, 'index']);
|
||||||
|
|
||||||
|
// Auth routes
|
||||||
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
|
Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
|
Route::get('/user', [AuthController::class, 'user']);
|
||||||
|
|
||||||
|
// User routes
|
||||||
|
Route::middleware(RoleMiddleware::class . ':user')->prefix('user')->group(function () {
|
||||||
|
Route::get('/dashboard', function () {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'User Dashboard',
|
||||||
|
'data' => [
|
||||||
|
'role' => 'user',
|
||||||
|
'dashboard' => 'student'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Konselor routes
|
||||||
|
Route::middleware(RoleMiddleware::class . ':konselor')->prefix('konselor')->group(function () {
|
||||||
|
Route::get('/dashboard', function () {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Konselor Dashboard',
|
||||||
|
'data' => [
|
||||||
|
'role' => 'konselor',
|
||||||
|
'dashboard' => 'konselor'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Operator routes
|
||||||
|
Route::middleware(RoleMiddleware::class . ':operator')->prefix('operator')->group(function () {
|
||||||
|
Route::get('/dashboard', function () {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Operator Dashboard',
|
||||||
|
'data' => [
|
||||||
|
'role' => 'operator',
|
||||||
|
'dashboard' => 'operator'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
|
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||||
|
|
||||||
|
$kernel->bootstrap();
|
||||||
|
|
||||||
|
echo "Backend server is ready!\n";
|
||||||
|
echo "Available routes:\n";
|
||||||
|
echo "- GET /api/test\n";
|
||||||
|
echo "- POST /api/login\n";
|
||||||
|
echo "- GET /api/user\n";
|
||||||
|
echo "- GET /api/contact\n";
|
||||||
|
echo "- GET /api/hero\n";
|
||||||
|
echo "- GET /api/articles\n";
|
||||||
|
|
||||||
|
// Test database connection
|
||||||
|
try {
|
||||||
|
\Illuminate\Support\Facades\DB::connection()->getPdo();
|
||||||
|
echo "\n✅ Database connection: OK\n";
|
||||||
|
|
||||||
|
// Check users
|
||||||
|
$userCount = \App\Models\User::count();
|
||||||
|
echo "✅ Users in database: {$userCount}\n";
|
||||||
|
|
||||||
|
if ($userCount > 0) {
|
||||||
|
echo "✅ Test users ready for login\n";
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo "\n❌ Database connection failed: " . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nTo start the server, run: php artisan serve\n";
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"framer-motion": "^10.0.1",
|
"framer-motion": "^10.0.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-router-dom": "^6.8.1",
|
||||||
"tailwindcss": "^3.4.19"
|
"tailwindcss": "^3.4.19"
|
||||||
},
|
},
|
||||||
|
|
@ -3482,6 +3483,15 @@
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"framer-motion": "^10.0.1",
|
"framer-motion": "^10.0.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-router-dom": "^6.8.1",
|
||||||
"tailwindcss": "^3.4.19"
|
"tailwindcss": "^3.4.19"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const AppMinimal = () => {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', fontFamily: 'Arial, sans-serif' }}>
|
||||||
|
<h1 style={{ color: '#E6E6FA', marginBottom: '20px' }}>PolijeCare - Test Page</h1>
|
||||||
|
<p style={{ color: '#666', marginBottom: '20px' }}>Halaman test untuk memastikan React berfungsi.</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ color: '#333', marginBottom: '10px' }}>Status System:</h2>
|
||||||
|
<ul style={{ color: '#666', lineHeight: '1.6' }}>
|
||||||
|
<li>✅ React berjalan</li>
|
||||||
|
<li>✅ CSS terload</li>
|
||||||
|
<li>✅ Tidak ada black screen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: '#E6E6FA',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ color: '#333', marginBottom: '10px' }}>Test Login:</h2>
|
||||||
|
<p style={{ color: '#666', marginBottom: '10px' }}>Akun test tersedia:</p>
|
||||||
|
<div style={{ backgroundColor: '#fff', padding: '10px', borderRadius: '4px' }}>
|
||||||
|
<strong>Mahasiswa:</strong> 2021001@student.polije.ac.id / password123<br/>
|
||||||
|
<strong>Konselor:</strong> siti@polije.ac.id / password123<br/>
|
||||||
|
<strong>Operator:</strong> budi@polije.ac.id / password123
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => alert('Test button berfungsi!')}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#4C6EF5',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Test Button
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppMinimal;
|
||||||
|
|
@ -1,29 +1,84 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
|
|
||||||
|
// Pages
|
||||||
import LandingPage from './pages/LandingPage';
|
import LandingPage from './pages/LandingPage';
|
||||||
import ArticleDetail from './components/ArticleDetail';
|
import UserDashboard from './pages/UserDashboard';
|
||||||
import './App.css';
|
import KonselorDashboard from './pages/KonselorDashboard';
|
||||||
|
import OperatorDashboard from './pages/OperatorDashboard';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<Router>
|
<AuthProvider>
|
||||||
<div className="App">
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public Routes */}
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/artikel" element={<LandingPage />} />
|
<Route path="/about" element={<LandingPage />} />
|
||||||
<Route path="/artikel/:slug" element={<ArticleDetail />} />
|
<Route path="/services" element={<LandingPage />} />
|
||||||
<Route path="/login" element={<LandingPage />} />
|
<Route path="/articles" element={<LandingPage />} />
|
||||||
<Route path="/user/dashboard" element={<LandingPage />} />
|
<Route path="/contact" element={<LandingPage />} />
|
||||||
<Route path="/operator/dashboard" element={<LandingPage />} />
|
|
||||||
<Route path="/konselor/dashboard" element={<LandingPage />} />
|
{/* Protected Dashboard Routes */}
|
||||||
<Route path="/redirect" element={<LandingPage />} />
|
<Route
|
||||||
|
path="/user/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRole="user">
|
||||||
|
<UserDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/konselor/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRole="konselor">
|
||||||
|
<KonselorDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/operator/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRole="operator">
|
||||||
|
<OperatorDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Redirect */}
|
||||||
|
<Route path="/redirect" element={<RedirectDashboard />} />
|
||||||
|
|
||||||
|
{/* 404 */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</Router>
|
||||||
</Router>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect component for role-based navigation
|
||||||
|
const RedirectDashboard = () => {
|
||||||
|
const { user, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (user.role) {
|
||||||
|
case 'user':
|
||||||
|
return <Navigate to="/user/dashboard" replace />;
|
||||||
|
case 'konselor':
|
||||||
|
return <Navigate to="/konselor/dashboard" replace />;
|
||||||
|
case 'operator':
|
||||||
|
return <Navigate to="/operator/dashboard" replace />;
|
||||||
|
default:
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { articleService } from '../services/articleService';
|
|
||||||
import { fadeIn, slideUp, staggerChildren } from '../utils/motionVariants';
|
import { fadeIn, slideUp, staggerChildren } from '../utils/motionVariants';
|
||||||
|
|
||||||
const Articles = () => {
|
const Articles = () => {
|
||||||
|
|
@ -10,25 +9,40 @@ const Articles = () => {
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchArticles();
|
// Temporary disable API calls to prevent CORS errors
|
||||||
}, []);
|
const mockArticles = [
|
||||||
|
{
|
||||||
const fetchArticles = async () => {
|
id: 1,
|
||||||
try {
|
title: 'Pentingnya Menjaga Lingkungan Kampus Aman dari Kekerasan Seksual',
|
||||||
setLoading(true);
|
slug: 'pentingnya-menjaga-lingkungan-kampus-aman-dari-kekerasan-seksual',
|
||||||
const response = await articleService.getAll();
|
image: 'articles/safe-campus.jpg',
|
||||||
if (response.success) {
|
content: 'Lingkungan kampus yang aman adalah hak setiap sivitas akademika.',
|
||||||
setArticles(response.data);
|
is_published: true,
|
||||||
} else {
|
published_at: '2024-01-07T00:00:00.000000Z'
|
||||||
setError('Failed to load articles');
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Prosedur Pelaporan Kasus Kekerasan Seksual di Polije',
|
||||||
|
slug: 'prosedur-pelaporan-kasus-kekerasan-seksual-di-polije',
|
||||||
|
image: 'articles/reporting-procedure.jpg',
|
||||||
|
content: 'Prosedur pelaporan kasus kekerasan seksual di Politeknik Negeri Jember.',
|
||||||
|
is_published: true,
|
||||||
|
published_at: '2024-01-05T00:00:00.000000Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Hak dan Kewajiban Korban dan Pelapor Kekerasan Seksual',
|
||||||
|
slug: 'hak-dan-kewajiban-korban-dan-pelapor-kekerasan-seksual',
|
||||||
|
image: 'articles/rights-responsibilities.jpg',
|
||||||
|
content: 'Sebagai korban atau pelapor kekerasan seksual, Anda memiliki hak-hak.',
|
||||||
|
is_published: true,
|
||||||
|
published_at: '2024-01-03T00:00:00.000000Z'
|
||||||
}
|
}
|
||||||
} catch (err) {
|
];
|
||||||
setError('Failed to load articles');
|
|
||||||
console.error('Error fetching articles:', err);
|
setArticles(mockArticles);
|
||||||
} finally {
|
setLoading(false);
|
||||||
setLoading(false);
|
}, []);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||||
|
|
@ -144,7 +158,7 @@ const Articles = () => {
|
||||||
>
|
>
|
||||||
{articles.map((article, index) => (
|
{articles.map((article, index) => (
|
||||||
<motion.article
|
<motion.article
|
||||||
key={article.uuid}
|
key={article.id || index}
|
||||||
className="bg-white rounded-2xl shadow-soft hover:shadow-card transition-all duration-300 hover:-translate-y-2 overflow-hidden group"
|
className="bg-white rounded-2xl shadow-soft hover:shadow-card transition-all duration-300 hover:-translate-y-2 overflow-hidden group"
|
||||||
variants={fadeIn}
|
variants={fadeIn}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { contactService } from '../services/contactService';
|
|
||||||
import { fadeIn, slideUp, staggerChildren } from '../utils/motionVariants';
|
import { fadeIn, slideUp, staggerChildren } from '../utils/motionVariants';
|
||||||
|
|
||||||
const Contact = () => {
|
const Contact = () => {
|
||||||
|
|
@ -9,26 +8,21 @@ const Contact = () => {
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchContactInfo();
|
// Temporary disable API calls to prevent 404 errors
|
||||||
|
const mockContactInfo = {
|
||||||
|
address: 'Jl. Mastrip PO Box 164, Jember 68121, Jawa Timur, Indonesia',
|
||||||
|
phone: '+62 331-123456',
|
||||||
|
email: 'satgasppkpt@polije.ac.id',
|
||||||
|
instagram: '@satgasppkpt_polije',
|
||||||
|
whatsapp: '+6281234567890',
|
||||||
|
facebook: 'SatgasPPKPTPolije',
|
||||||
|
twitter: '@SatgasPPKPTPolije'
|
||||||
|
};
|
||||||
|
|
||||||
|
setContactInfo(mockContactInfo);
|
||||||
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchContactInfo = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await contactService.get();
|
|
||||||
if (response.success) {
|
|
||||||
setContactInfo(response.data);
|
|
||||||
} else {
|
|
||||||
setError('Failed to load contact information');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load contact information');
|
|
||||||
console.error('Error fetching contact info:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultContact = {
|
const defaultContact = {
|
||||||
address: 'Jl. Mastrip PO Box 164, Jember 68121, Jawa Timur, Indonesia',
|
address: 'Jl. Mastrip PO Box 164, Jember 68121, Jawa Timur, Indonesia',
|
||||||
phone: '+62 331-123456',
|
phone: '+62 331-123456',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { FiMail, FiLock, FiAlertCircle, FiEye, FiEyeOff, FiX } from 'react-icons/fi';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
// Motion variants for animations
|
||||||
|
const fadeIn = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { duration: 0.3 } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const slideUp = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { duration: 0.3, delay: 0.1 } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalVariants = {
|
||||||
|
hidden: { opacity: 0, scale: 0.95 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.2,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.95,
|
||||||
|
transition: { duration: 0.2 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const backdropVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { duration: 0.2 } },
|
||||||
|
exit: { opacity: 0, transition: { duration: 0.2 } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginModal = ({ isOpen, onClose }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
const validateEmail = (email) => {
|
||||||
|
const emailRegex = /^[^\s@]+@polije\.ac\.id$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors = {};
|
||||||
|
|
||||||
|
if (!formData.email) {
|
||||||
|
newErrors.email = 'Email wajib diisi';
|
||||||
|
} else if (!validateEmail(formData.email)) {
|
||||||
|
newErrors.email = 'Email harus menggunakan domain @polije.ac.id';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
newErrors.password = 'Password wajib diisi';
|
||||||
|
} else if (formData.password.length < 6) {
|
||||||
|
newErrors.password = 'Password minimal 6 karakter';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear login error when user starts typing
|
||||||
|
if (loginError) {
|
||||||
|
setLoginError('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoginError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await login({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔍 Login result:', result);
|
||||||
|
|
||||||
|
if (result && result.success) {
|
||||||
|
console.log('✅ Login successful');
|
||||||
|
onClose(); // Close modal
|
||||||
|
navigate('/redirect');
|
||||||
|
} else {
|
||||||
|
console.error('❌ Login failed:', result);
|
||||||
|
const errorMessage = result?.message || result?.error || 'Login gagal. Silakan coba lagi.';
|
||||||
|
setLoginError(errorMessage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Login error:', error);
|
||||||
|
console.error('Error details:', error.response?.data || error.message);
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || 'Terjadi kesalahan. Silakan coba lagi.';
|
||||||
|
setLoginError(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePasswordVisibility = () => {
|
||||||
|
setShowPassword(!showPassword);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!isLoading) {
|
||||||
|
onClose();
|
||||||
|
// Reset form
|
||||||
|
setFormData({ email: '', password: '' });
|
||||||
|
setErrors({});
|
||||||
|
setLoginError('');
|
||||||
|
setShowPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = (e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
variants={backdropVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
exit="exit"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden"
|
||||||
|
variants={modalVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
exit="exit"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="absolute top-4 right-4 z-10 p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<FiX className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div className="p-6 sm:p-8">
|
||||||
|
{/* Header with Protection Icon */}
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-6"
|
||||||
|
variants={slideUp}
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-primary to-accent rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||||
|
<img
|
||||||
|
src="/logo_polijecare.png"
|
||||||
|
alt="Polijecare Logo"
|
||||||
|
className="w-10 h-10 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
PolijeCare
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Portal Layanan Perlindungan & Kesejahteraan
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Login Form */}
|
||||||
|
<motion.form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
variants={slideUp}
|
||||||
|
>
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 text-center leading-relaxed">
|
||||||
|
Masuk menggunakan akun resmi Polije<br />
|
||||||
|
untuk menjaga keamanan dan kerahasiaan data Anda.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Error Alert */}
|
||||||
|
{loginError && (
|
||||||
|
<motion.div
|
||||||
|
className="mb-6 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-3 text-red-600 dark:text-red-400 text-sm"
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<FiAlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{loginError}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Field */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Email Polije
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FiMail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full pl-10 pr-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors dark:bg-gray-700 dark:border-gray-600 dark:text-white ${
|
||||||
|
errors.email ? 'border-red-300 dark:border-red-600' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="nama@polije.ac.id"
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.email && (
|
||||||
|
<motion.p
|
||||||
|
className="mt-1 text-sm text-red-600 dark:text-red-400"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
>
|
||||||
|
{errors.email}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FiLock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full pl-10 pr-12 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors dark:bg-gray-700 dark:border-gray-600 dark:text-white ${
|
||||||
|
errors.password ? 'border-red-300 dark:border-red-600' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Masukkan password"
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
onClick={togglePasswordVisibility}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{showPassword ? <FiEyeOff className="w-5 h-5" /> : <FiEye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<motion.p
|
||||||
|
className="mt-1 text-sm text-red-600 dark:text-red-400"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
>
|
||||||
|
{errors.password}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3 px-4 bg-primary text-white font-medium rounded-lg shadow-lg hover:bg-primary-dark hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
|
||||||
|
disabled={isLoading}
|
||||||
|
whileHover={{ scale: isLoading ? 1 : 1.02 }}
|
||||||
|
whileTap={{ scale: isLoading ? 1 : 0.98 }}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||||
|
<span>Masuk...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'Masuk'
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</motion.form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<motion.div
|
||||||
|
className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"
|
||||||
|
variants={slideUp}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center leading-relaxed">
|
||||||
|
Dilindungi dengan enkripsi end-to-end<br />
|
||||||
|
© 2026 PolijeCare - Politeknik Negeri Jember
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Background Decoration */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||||
|
<div className="absolute -top-20 -right-20 w-40 h-40 bg-gradient-to-br from-primary/10 to-accent/5 rounded-full blur-2xl"></div>
|
||||||
|
<div className="absolute -bottom-16 -left-16 w-32 h-32 bg-gradient-to-br from-accent/10 to-primary/5 rounded-full blur-xl"></div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginModal;
|
||||||
|
|
@ -4,10 +4,12 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { fadeIn, slideDown } from '../utils/motionVariants';
|
import { fadeIn, slideDown } from '../utils/motionVariants';
|
||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from './ThemeToggle';
|
||||||
|
import LoginModal from './LoginModal';
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||||
const { isAuthenticated, user, logout } = useAuth();
|
const { isAuthenticated, user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -78,22 +80,15 @@ const Navbar = () => {
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link
|
<Link to="/" className="flex items-center space-x-3">
|
||||||
to="/"
|
<div className="w-10 h-10 bg-gradient-to-br from-primary to-accent rounded-xl flex items-center justify-center shadow-lg">
|
||||||
className="flex items-center space-x-3 group"
|
<img
|
||||||
onClick={() => handleNavClick('#hero')}
|
src="/logo_polijecare.png"
|
||||||
>
|
alt="Polijecare Logo"
|
||||||
<div className="relative">
|
className="w-8 h-8 object-contain"
|
||||||
<div className="w-12 h-12 bg-white rounded-xl flex items-center justify-center shadow-soft group-hover:shadow-card transition-all duration-300 border border-primary/20">
|
/>
|
||||||
<img
|
|
||||||
src="/logo_polijecare.png"
|
|
||||||
alt="PolijeCare Logo"
|
|
||||||
className="w-10 h-10 object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-accent rounded-full animate-pulse"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-bold text-gray-900 group-hover:text-primary transition-colors">
|
<span className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
Polijecare
|
Polijecare
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -151,12 +146,12 @@ const Navbar = () => {
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<button
|
||||||
to="/login"
|
onClick={() => setIsLoginModalOpen(true)}
|
||||||
className="px-6 py-2.5 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 hover:shadow-soft font-medium"
|
className="px-6 py-2.5 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 hover:shadow-soft font-medium"
|
||||||
>
|
>
|
||||||
Masuk
|
Masuk
|
||||||
</Link>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
|
|
@ -259,13 +254,15 @@ const Navbar = () => {
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<button
|
||||||
to="/login"
|
onClick={() => {
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
setIsLoginModalOpen(true);
|
||||||
className="block w-full px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium text-center"
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium"
|
||||||
>
|
>
|
||||||
Masuk
|
Masuk
|
||||||
</Link>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -273,6 +270,12 @@ const Navbar = () => {
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.nav>
|
</motion.nav>
|
||||||
|
|
||||||
|
{/* Login Modal */}
|
||||||
|
<LoginModal
|
||||||
|
isOpen={isLoginModalOpen}
|
||||||
|
onClose={() => setIsLoginModalOpen(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const ProtectedRoute = ({ children, requiredRole }) => {
|
||||||
|
const { user, isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
// Show loading while checking auth
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role if specified
|
||||||
|
if (requiredRole && user?.role !== requiredRole) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-6xl text-red-500 mb-4">🚫</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Akses Ditolak</h1>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Anda tidak memiliki izin untuk mengakses halaman ini.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
Kembali
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render children if authenticated and authorized
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
||||||
|
import axios from '../utils/axiosConfig';
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState = {
|
||||||
|
user: null,
|
||||||
|
token: localStorage.getItem('token'),
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false, // Changed to false initially
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Action types
|
||||||
|
const AUTH_ACTIONS = {
|
||||||
|
LOGIN_START: 'LOGIN_START',
|
||||||
|
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
|
||||||
|
LOGIN_FAILURE: 'LOGIN_FAILURE',
|
||||||
|
LOGOUT: 'LOGOUT',
|
||||||
|
LOAD_USER_START: 'LOAD_USER_START',
|
||||||
|
LOAD_USER_SUCCESS: 'LOAD_USER_SUCCESS',
|
||||||
|
LOAD_USER_FAILURE: 'LOAD_USER_FAILURE',
|
||||||
|
CLEAR_ERROR: 'CLEAR_ERROR',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reducer
|
||||||
|
const authReducer = (state, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case AUTH_ACTIONS.LOGIN_START:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
case AUTH_ACTIONS.LOGIN_SUCCESS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: action.payload.user,
|
||||||
|
token: action.payload.token,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
case AUTH_ACTIONS.LOGIN_FAILURE:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
error: action.payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
case AUTH_ACTIONS.LOGOUT:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
case AUTH_ACTIONS.LOAD_USER_START:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isLoading: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
case AUTH_ACTIONS.LOAD_USER_SUCCESS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user: action.payload,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
case AUTH_ACTIONS.LOAD_USER_FAILURE:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
token: null,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
case AUTH_ACTIONS.CLEAR_ERROR:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
const AuthContext = createContext();
|
||||||
|
|
||||||
|
// Provider component
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||||
|
|
||||||
|
// Set axios default header
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.token) {
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${state.token}`;
|
||||||
|
} else {
|
||||||
|
delete axios.defaults.headers.common['Authorization'];
|
||||||
|
}
|
||||||
|
}, [state.token]);
|
||||||
|
|
||||||
|
// Load user on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadUser = async () => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// Set token immediately to prevent white screen
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||||
|
dispatch({ type: AUTH_ACTIONS.LOAD_USER_START });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/user');
|
||||||
|
if (response.data.success) {
|
||||||
|
dispatch({
|
||||||
|
type: AUTH_ACTIONS.LOAD_USER_SUCCESS,
|
||||||
|
payload: response.data.user,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dispatch({ type: AUTH_ACTIONS.LOAD_USER_FAILURE });
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
delete axios.defaults.headers.common['Authorization'];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load user error:', error);
|
||||||
|
dispatch({ type: AUTH_ACTIONS.LOAD_USER_FAILURE });
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
delete axios.defaults.headers.common['Authorization'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No token - set loading to false immediately
|
||||||
|
dispatch({
|
||||||
|
type: AUTH_ACTIONS.LOAD_USER_SUCCESS,
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadUser();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Login function
|
||||||
|
const login = async (credentials) => {
|
||||||
|
try {
|
||||||
|
dispatch({ type: AUTH_ACTIONS.LOGIN_START });
|
||||||
|
|
||||||
|
console.log('🔍 Attempting login to:', '/api/login');
|
||||||
|
console.log('🔍 Credentials:', { email: credentials.email, password: '***' });
|
||||||
|
|
||||||
|
const response = await axios.post('/api/login', credentials);
|
||||||
|
|
||||||
|
console.log('🔍 Auth response:', response.data);
|
||||||
|
console.log('🔍 Response status:', response.status);
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
const { token, user } = response.data;
|
||||||
|
|
||||||
|
// Store token
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
|
||||||
|
// Set axios header
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: AUTH_ACTIONS.LOGIN_SUCCESS,
|
||||||
|
payload: { token, user },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: response.data };
|
||||||
|
} else {
|
||||||
|
const message = response.data?.message || 'Login gagal';
|
||||||
|
console.error('❌ Login failed:', message);
|
||||||
|
return { success: false, message };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Auth error:', error);
|
||||||
|
console.error('❌ Error config:', error.config);
|
||||||
|
console.error('❌ Error response:', error.response);
|
||||||
|
console.error('❌ Error status:', error.response?.status);
|
||||||
|
console.error('❌ Error data:', error.response?.data);
|
||||||
|
|
||||||
|
// Handle different error types
|
||||||
|
let errorMessage = 'Login gagal. Silakan coba lagi.';
|
||||||
|
|
||||||
|
if (error.code === 'ECONNREFUSED' || error.code === 'ERR_NETWORK') {
|
||||||
|
errorMessage = 'Tidak dapat terhubung ke server. Pastikan backend berjalan.';
|
||||||
|
} else if (error.response?.status === 401) {
|
||||||
|
errorMessage = error.response.data?.message || 'Email atau password salah.';
|
||||||
|
} else if (error.response?.status === 422) {
|
||||||
|
errorMessage = 'Validasi gagal. Periksa kembali input Anda.';
|
||||||
|
} else if (error.response?.data?.message) {
|
||||||
|
errorMessage = error.response.data.message;
|
||||||
|
} else if (error.message) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: AUTH_ACTIONS.LOGIN_FAILURE,
|
||||||
|
payload: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logout function
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
if (state.token) {
|
||||||
|
await axios.post('/api/logout');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
delete axios.defaults.headers.common['Authorization'];
|
||||||
|
dispatch({ type: AUTH_ACTIONS.LOGOUT });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear error
|
||||||
|
const clearError = () => {
|
||||||
|
dispatch({ type: AUTH_ACTIONS.CLEAR_ERROR });
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
...state,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom hook to use auth context
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthContext;
|
||||||
|
|
@ -278,10 +278,31 @@ body {
|
||||||
.hover\:bg-green-600:hover,
|
.hover\:bg-green-600:hover,
|
||||||
.hover\:bg-green-700:hover,
|
.hover\:bg-green-700:hover,
|
||||||
.hover\:bg-green-800:hover,
|
.hover\:bg-green-800:hover,
|
||||||
.hover\:bg-green-900:hover {
|
.hover\:bg-green-900:hover,
|
||||||
|
.from-green-50,
|
||||||
|
.from-green-100,
|
||||||
|
.from-green-200,
|
||||||
|
.from-green-300,
|
||||||
|
.from-green-400,
|
||||||
|
.from-green-500,
|
||||||
|
.from-green-600,
|
||||||
|
.from-green-700,
|
||||||
|
.from-green-800,
|
||||||
|
.from-green-900,
|
||||||
|
.to-green-50,
|
||||||
|
.to-green-100,
|
||||||
|
.to-green-200,
|
||||||
|
.to-green-300,
|
||||||
|
.to-green-400,
|
||||||
|
.to-green-500,
|
||||||
|
.to-green-600,
|
||||||
|
.to-green-700,
|
||||||
|
.to-green-800,
|
||||||
|
.to-green-900 {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
border-color: transparent !important;
|
border-color: transparent !important;
|
||||||
|
background-image: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Force remove green from any element */
|
/* Force remove green from any element */
|
||||||
|
|
@ -289,6 +310,7 @@ body {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
border-color: transparent !important;
|
border-color: transparent !important;
|
||||||
|
background-image: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Additional Variables */
|
/* Additional Variables */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { FiUsers, FiCalendar, FiFileText, FiCheckCircle } from 'react-icons/fi';
|
||||||
|
|
||||||
|
const KonselorDashboard = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Dashboard Konselor</h1>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-sm text-gray-600">Konselor</span>
|
||||||
|
<div className="w-8 h-8 bg-accent rounded-full flex items-center justify-center">
|
||||||
|
<FiUsers className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Welcome Section */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-gradient-to-r from-accent to-primary rounded-2xl p-8 mb-8 text-white"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Dashboard Konseling</h2>
|
||||||
|
<p className="opacity-90">
|
||||||
|
Kelola dan monitoring layanan konseling untuk sivitas akademika
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm"
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiFileText className="w-8 h-8 text-primary" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">45</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Total Laporan</h3>
|
||||||
|
<p className="text-sm text-gray-600">Semua laporan masuk</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm"
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiCalendar className="w-8 h-8 text-accent" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">8</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Jadwal Hari Ini</h3>
|
||||||
|
<p className="text-sm text-gray-600">Sesi konseling</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm"
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiCheckCircle className="w-8 h-8 text-green-500" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">32</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Selesai</h3>
|
||||||
|
<p className="text-sm text-gray-600">Laporan selesai</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm"
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiUsers className="w-8 h-8 text-purple-500" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">13</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Sedang Diproses</h3>
|
||||||
|
<p className="text-sm text-gray-600">Laporan aktif</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Reports */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl shadow-sm p-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.4 }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Laporan Membutuhkan Perhatian</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((item) => (
|
||||||
|
<div key={item} className="flex items-center justify-between p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900">Kasus #{item}</h4>
|
||||||
|
<p className="text-sm text-gray-600">Prioritas Tinggi • 1 jam lalu</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Lihat Detail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KonselorDashboard;
|
||||||
|
|
@ -6,32 +6,28 @@ import Services from '../components/Services';
|
||||||
import Articles from '../components/Articles';
|
import Articles from '../components/Articles';
|
||||||
import Contact from '../components/Contact';
|
import Contact from '../components/Contact';
|
||||||
import Footer from '../components/Footer';
|
import Footer from '../components/Footer';
|
||||||
import { heroService } from '../services/heroService';
|
import LoginModal from '../components/LoginModal';
|
||||||
|
|
||||||
const LandingPage = () => {
|
const LandingPage = () => {
|
||||||
const [heroData, setHeroData] = useState(null);
|
const [heroData, setHeroData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHeroData();
|
// Temporary disable API calls to prevent 404 errors
|
||||||
|
const mockHeroData = {
|
||||||
|
title: 'Aman Bicara, Aman Melapor',
|
||||||
|
subtitle: 'Satgas PPKPT Politeknik Negeri Jember',
|
||||||
|
description: 'Kami siap mendengar dan membantu Anda dengan profesionalisme dan kerahasiaan terjamin. Setiap laporan akan ditangani dengan empati dan seksama.'
|
||||||
|
};
|
||||||
|
|
||||||
|
setHeroData(mockHeroData);
|
||||||
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchHeroData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await heroService.get();
|
|
||||||
if (response.success) {
|
|
||||||
setHeroData(response.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching hero data:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
<LoginModal />
|
||||||
<Hero heroData={heroData} />
|
<Hero heroData={heroData} />
|
||||||
<About />
|
<About />
|
||||||
<Services />
|
<Services />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { FiSettings, FiUsers, FiFileText, FiEdit, FiDatabase } from 'react-icons/fi';
|
||||||
|
|
||||||
|
const OperatorDashboard = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Dashboard Operator</h1>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-sm text-gray-600">Operator</span>
|
||||||
|
<div className="w-8 h-8 bg-red-500 rounded-full flex items-center justify-center">
|
||||||
|
<FiSettings className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Welcome Section */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl p-8 mb-8 text-white"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Sistem Administrasi</h2>
|
||||||
|
<p className="opacity-90">
|
||||||
|
Kelola seluruh sistem PolijeCare dan monitoring aktivitas
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Admin Functions */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiUsers className="w-8 h-8 text-blue-500" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">156</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Manajemen Pengguna</h3>
|
||||||
|
<p className="text-sm text-gray-600">Kelola akun pengguna</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiFileText className="w-8 h-8 text-green-500" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">89</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Manajemen Pengaduan</h3>
|
||||||
|
<p className="text-sm text-gray-600">Kelola semua pengaduan</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiEdit className="w-8 h-8 text-purple-500" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">24</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Manajemen Artikel</h3>
|
||||||
|
<p className="text-sm text-gray-600">Kelola konten artikel</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiDatabase className="w-8 h-8 text-orange-500" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">12</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Manajemen Kategori</h3>
|
||||||
|
<p className="text-sm text-gray-600">Kelola kategori laporan</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiSettings className="w-8 h-8 text-red-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-900">Pengaturan</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Setting Admin</h3>
|
||||||
|
<p className="text-sm text-gray-600">Konfigurasi sistem</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiFileText className="w-8 h-8 text-indigo-500" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">5</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Audit Trail</h3>
|
||||||
|
<p className="text-sm text-gray-600">Log aktivitas sistem</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Status */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl shadow-sm p-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Status Sistem</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<h4 className="font-medium text-green-800 mb-2">Database</h4>
|
||||||
|
<p className="text-sm text-green-600">Normal • 99.9% Uptime</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h4 className="font-medium text-blue-800 mb-2">API Server</h4>
|
||||||
|
<p className="text-sm text-blue-600">Normal • Response: 45ms</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<h4 className="font-medium text-yellow-800 mb-2">Storage</h4>
|
||||||
|
<p className="text-sm text-yellow-600">Warning • 85% Used</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OperatorDashboard;
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { FiFileText, FiClock, FiUser, FiPlus } from 'react-icons/fi';
|
||||||
|
|
||||||
|
const UserDashboard = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Dashboard Mahasiswa</h1>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-sm text-gray-600">Mahasiswa</span>
|
||||||
|
<div className="w-8 h-8 bg-primary rounded-full flex items-center justify-center">
|
||||||
|
<FiUser className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Welcome Section */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-gradient-to-r from-primary to-accent rounded-2xl p-8 mb-8 text-white"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Selamat Datang di PolijeCare</h2>
|
||||||
|
<p className="opacity-90">
|
||||||
|
Portal layanan perlindungan dan kesejahteraan sivitas akademika Politeknik Negeri Jember
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiPlus className="w-8 h-8 text-primary" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">+</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Buat Laporan</h3>
|
||||||
|
<p className="text-sm text-gray-600">Ajukan pengaduan atau konseling</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiFileText className="w-8 h-8 text-accent" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">12</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Riwayat Laporan</h3>
|
||||||
|
<p className="text-sm text-gray-600">Lihat semua laporan Anda</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiClock className="w-8 h-8 text-green-500" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">3</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Sedang Diproses</h3>
|
||||||
|
<p className="text-sm text-gray-600">Laporan dalam proses</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<FiUser className="w-8 h-8 text-purple-500" />
|
||||||
|
<span className="text-sm font-medium text-primary">Profil</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Pengaturan</h3>
|
||||||
|
<p className="text-sm text-gray-600">Kelola akun Anda</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Reports */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-xl shadow-sm p-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Laporan Terbaru</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((item) => (
|
||||||
|
<div key={item} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900">Laporan #{item}</h4>
|
||||||
|
<p className="text-sm text-gray-600">Dibuat 2 hari yang lalu</p>
|
||||||
|
</div>
|
||||||
|
<span className="px-3 py-1 bg-yellow-100 text-yellow-800 text-sm rounded-full">
|
||||||
|
Sedang Diproses
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserDashboard;
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: 'http://localhost:8000', // Backend URL
|
||||||
|
timeout: 10000,
|
||||||
|
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 for error handling
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
// Handle 401 Unauthorized - only redirect if not already on home page
|
||||||
|
if (error.response?.status === 401 && window.location.pathname !== '/') {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
delete api.defaults.headers.common['Authorization'];
|
||||||
|
// Use navigate instead of direct location change to avoid infinite loop
|
||||||
|
console.log('🔐 Token expired, redirecting to home...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 403 Forbidden
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
console.error('Access forbidden - insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
Loading…
Reference in New Issue