Implement login page with email/password authentication #69

Merged
FernandoJVideira merged 6 commits from feature/login-page into develop 2025-12-22 17:08:38 +00:00
17 changed files with 498 additions and 74 deletions
Showing only changes of commit ae6a0b4706 - Show all commits
+3
View File
@@ -0,0 +1,3 @@
{
"postman.settings.dotenv-detection-notification-visibility": false
}
@@ -5,6 +5,8 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use App\Models\User;
use App\Http\Requests\RegisterRequest;
class AuthController extends Controller class AuthController extends Controller
{ {
@@ -38,4 +40,30 @@ class AuthController extends Controller
'message' => 'Logged out successfully', 'message' => 'Logged out successfully',
]); ]);
} }
public function register(RegisterRequest $request)
{
if (User::where('email', $request->email)->exists()) {
return response()->json(['message' => 'Email already registered'], 409);
}
$user = User::create([
'email' => $request->email,
'nickname' => $request->nickname,
'name' => $request->name,
'password' => bcrypt($request->password),
'photo_avatar_filename' => $request->photo,
'type' => 'P',
'blocked' => false,
'coins_balance' => 10,
]);
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'message' => 'User registered successfully',
'user' => $user,
'token' => $token,
], 201);
}
} }
+97 -50
View File
@@ -4,15 +4,14 @@ namespace App\Http\Controllers;
use App\Models\Game; use App\Models\Game;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\StoreGameRequest;
use Illuminate\Support\Facades\Auth;
class GameController extends Controller class GameController extends Controller
{ {
/**
* Display a listing of the resource.
*/
public function index(Request $request) public function index(Request $request)
{ {
$query = Game::query()->with(['winner']); $query = Game::query()->with(['winner', 'player1', 'player2']);
if ($request->has('type') && in_array($request->type, ['3', '9'])) { if ($request->has('type') && in_array($request->type, ['3', '9'])) {
$query->where('type', $request->type); $query->where('type', $request->type);
@@ -22,66 +21,114 @@ class GameController extends Controller
$query->where('status', $request->status); $query->where('status', $request->status);
} }
// Sorting $query->orderBy('began_at', 'desc');
$sortField = $request->input('sort_by', 'began_at');
$sortDirection = $request->input('sort_direction', 'desc');
$allowedSortFields = [ return response()->json($query->paginate(15));
'began_at',
'ended_at',
'total_time',
'type',
'status'
];
if (in_array($sortField, $allowedSortFields)) {
$query->orderBy($sortField, $sortDirection === 'asc' ? 'asc' : 'desc');
} }
// Pagination
$perPage = $request->input('per_page', 15);
$games = $query->paginate($perPage);
return response()->json([
'data' => $games->items(),
'meta' => [
'current_page' => $games->currentPage(),
'last_page' => $games->lastPage(),
'per_page' => $games->perPage(),
'total' => $games->total()
]
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request) public function store(Request $request)
{ {
// $validated = $request->validate([
'type' => 'required|in:3,9',
]);
$user = Auth::user();
$activeGame = Game::where(function ($query) use ($user) {
$query->where('player1_user_id', $user->id)
->orWhere('player2_user_id', $user->id);
})
->whereIn('status', ['Pending', 'Playing'])
->exists();
if ($activeGame) {
return response()->json(['message' => 'You already have an active game.'], 400);
}
$game = Game::create([
'type' => $validated['type'],
'status' => 'Pending',
'player1_user_id' => $user->id,
'player2_user_id' => $user->id,
'winner_user_id' => $user->id,
'loser_user_id' => $user->id,
'began_at' => now(),
'player1_points' => 0,
'player2_points' => 0,
'custom' => null
]);
return response()->json([
'message' => 'Multiplayer game room created',
'game' => $game
], 201);
}
public function join(Game $game)
{
$user = Auth::user();
if ($game->player1_user_id == $user->id) {
return response()->json([
'message' => 'Waiting for opponent...',
'game' => $game
], 200);
}
if ($game->status !== 'Pending') {
return response()->json(['message' => 'Game is full or already started'], 400);
}
$activeGame = Game::where(function ($query) use ($user) {
$query->where('player1_user_id', $user->id)
->orWhere('player2_user_id', $user->id);
})
->whereIn('status', ['Pending', 'Playing'])
->exists();
if ($activeGame) {
return response()->json(['message' => 'You are already in another active game.'], 400);
}
$game->update([
'status' => 'Playing',
'player2_user_id' => $user->id,
]);
return response()->json([
'message' => 'Joined successfully! Game starts now.',
'game' => $game
], 200);
} }
/**
* Display the specified resource.
*/
public function show(Game $game) public function show(Game $game)
{ {
// $game->load(['player1', 'player2', 'winner']);
return response()->json($game);
} }
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Game $game) public function update(Request $request, Game $game)
{ {
// if ($game->status == 'Ended') {
return response()->json(['message' => 'Game already ended'], 400);
} }
/** $data = $request->validate([
* Remove the specified resource from storage. 'status' => 'in:Playing,Ended,Interrupted',
*/ 'winner_user_id' => 'exists:users,id',
public function destroy(Game $game) 'loser_user_id' => 'exists:users,id',
{ 'player1_points' => 'integer',
// 'player2_points' => 'integer',
'is_draw' => 'boolean',
'total_time' => 'numeric'
]);
if (isset($data['status']) && $data['status'] === 'Ended') {
$data['ended_at'] = now();
}
$game->update($data);
return response()->json($game);
} }
} }
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Game;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
class UserController extends Controller
{
/**
* GET /api/users
* Lista todos os utilizadores (Apenas para Admins)
*/
public function index(Request $request)
{
if ($request->user()->type !== 'A') {
return response()->json(['message' => 'Unauthorized'], 403);
}
$query = User::query();
// Filtros úteis para o Backoffice
if ($request->has('type')) {
$query->where('type', $request->type);
}
if ($request->has('search')) {
$query->where('name', 'like', '%' . $request->search . '%')
->orWhere('email', 'like', '%' . $request->search . '%')
->orWhere('nickname', 'like', '%' . $request->search . '%');
}
return response()->json($query->paginate(15));
}
/**
* GET /api/users/me
* Retorna o perfil do utilizador autenticado atual.
* Usado pelo VueJS para saber quem está logado.
*/
public function me(Request $request)
{
return response()->json($request->user());
}
/**
* GET /api/users/{user}
* Mostra um perfil específico.
*/
public function show(User $user)
{
$currentUser = Auth::user();
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') {
return response()->json([
'id' => $user->id,
'name' => $user->name,
'nickname' => $user->nickname,
'photo_avatar_filename' => $user->photo_avatar_filename,
'type' => $user->type
]);
}
return response()->json($user);
}
/**
* PUT/PATCH /api/users/{user}
* Atualizar perfil (Nome, Nickname, Password, Foto)
*/
public function update(Request $request, User $user)
{
$currentUser = Auth::user();
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') {
return response()->json(['message' => 'Unauthorized'], 403);
}
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'nickname' => ['sometimes', 'string', 'max:20', Rule::unique('users')->ignore($user->id)],
'email' => ['sometimes', 'email', Rule::unique('users')->ignore($user->id)],
'password' => 'sometimes|string|min:3',
'blocked' => 'sometimes|boolean',
]);
if (isset($validated['blocked']) && $currentUser->type !== 'A') {
return response()->json(['message' => 'Only admins can block users'], 403);
}
// Lógica de Upload de Foto (Exemplo Básico)
// if ($request->hasFile('photo_avatar')) {
// $path = $request->file('photo_avatar')->store('avatars', 'public');
// $validated['photo_avatar_filename'] = basename($path);
// }
$user->update($validated);
return response()->json($user);
}
/**
* DELETE /api/users/{user}
* Apagar conta (Soft Delete)
*/
public function destroy(User $user)
{
$currentUser = Auth::user();
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') {
return response()->json(['message' => 'Unauthorized'], 403);
}
if ($currentUser->id === $user->id && $currentUser->type === 'A') {
if ($user->type === 'A') {
return response()->json(['message' => 'Admins cannot delete their own account'], 403);
}
}
$user->delete();
return response()->json(['message' => 'User deleted successfully']);
}
/**
* GET /api/users/{user}/matches
* Histórico de Jogos Multiplayer do Utilizador
*/
public function getMatches(Request $request, User $user)
{
$currentUser = Auth::user();
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') {
return response()->json(['message' => 'Unauthorized'], 403);
}
$matches = Game::query()
->where(function($q) use ($user) {
$q->where('player1_user_id', $user->id)
->orWhere('player2_user_id', $user->id);
})
->with(['winner', 'player1', 'player2'])
->orderBy('began_at', 'desc')
->paginate(10);
return response()->json($matches);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RegisterRequest 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|unique:users,email',
'nickname' => 'required|string|max:255',
'name' => 'required|string|max:255',
'password' => 'required|string|min:3',
'photo' => 'nullable|string',
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class StoreGameRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// Só utilizadores autenticados podem criar jogos
return Auth::check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
// Agora validamos diretamente '3' ou '9' para bater certo com a BD
'type' => 'required|in:3,9',
];
}
/**
* Mensagens personalizadas (Opcional)
*/
public function messages(): array
{
return [
'type.required' => 'You must choose a game type (Bisca de 3 or 9).',
'type.in' => 'Invalid game type. Choose 3 or 9.',
];
}
}
+34
View File
@@ -3,11 +3,45 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Http\Requests\StoreGameRequest;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Game extends Model class Game extends Model
{ {
use HasFactory;
public $timestamps = false;
protected $fillable = [
'type',
'status',
'player1_user_id',
'player2_user_id',
'winner_user_id',
'loser_user_id',
'began_at',
'ended_at',
'total_time',
'player1_points',
'player2_points',
'custom',
'is_draw',
'match_id'
];
public function winner() public function winner()
{ {
return $this->belongsTo(User::class, "winner_user_id"); return $this->belongsTo(User::class, "winner_user_id");
} }
public function player1()
{
return $this->belongsTo(User::class, 'player1_user_id');
}
public function player2()
{
return $this->belongsTo(User::class, 'player2_user_id');
}
} }
+9 -1
View File
@@ -4,6 +4,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
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; use Laravel\Sanctum\HasApiTokens;
@@ -11,7 +12,7 @@ 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, HasApiTokens; use HasFactory, Notifiable, HasApiTokens, SoftDeletes;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@@ -22,6 +23,12 @@ class User extends Authenticatable
'name', 'name',
'email', 'email',
'password', 'password',
'nickname',
'photo_avatar_filename',
'type',
'blocked',
'coins_balance',
'custom',
]; ];
/** /**
@@ -44,6 +51,7 @@ class User extends Authenticatable
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'blocked' => 'boolean',
]; ];
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.24", "laravel/pint": "^1.26",
"laravel/sail": "^1.48", "laravel/sail": "^1.48",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
+15 -14
View File
@@ -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": "6ad8f0aa2267878e2374cd4ba4042946", "content-hash": "353b4e6bf34bd12906db0059bb3a9207",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -6575,16 +6575,16 @@
}, },
{ {
"name": "laravel/pint", "name": "laravel/pint",
"version": "v1.25.1", "version": "v1.26.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/pint.git", "url": "https://github.com/laravel/pint.git",
"reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f",
"reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6595,13 +6595,13 @@
"php": "^8.2.0" "php": "^8.2.0"
}, },
"require-dev": { "require-dev": {
"friendsofphp/php-cs-fixer": "^3.87.2", "friendsofphp/php-cs-fixer": "^3.90.0",
"illuminate/view": "^11.46.0", "illuminate/view": "^12.40.1",
"larastan/larastan": "^3.7.1", "larastan/larastan": "^3.8.0",
"laravel-zero/framework": "^11.45.0", "laravel-zero/framework": "^12.0.4",
"mockery/mockery": "^1.6.12", "mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.3.1", "nunomaduro/termwind": "^2.3.3",
"pestphp/pest": "^2.36.0" "pestphp/pest": "^3.8.4"
}, },
"bin": [ "bin": [
"builds/pint" "builds/pint"
@@ -6627,6 +6627,7 @@
"description": "An opinionated code formatter for PHP.", "description": "An opinionated code formatter for PHP.",
"homepage": "https://laravel.com", "homepage": "https://laravel.com",
"keywords": [ "keywords": [
"dev",
"format", "format",
"formatter", "formatter",
"lint", "lint",
@@ -6637,7 +6638,7 @@
"issues": "https://github.com/laravel/pint/issues", "issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint" "source": "https://github.com/laravel/pint"
}, },
"time": "2025-09-19T02:57:12+00:00" "time": "2025-11-25T21:15:52+00:00"
}, },
{ {
"name": "laravel/sail", "name": "laravel/sail",
@@ -9380,12 +9381,12 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": {}, "stability-flags": [],
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": {}, "platform-dev": [],
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"
} }
+1 -1
View File
@@ -6,7 +6,7 @@
"": { "": {
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0", "axios": "^1.13.2",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
+1 -1
View File
@@ -8,7 +8,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0", "axios": "^1.13.2",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
+12 -2
View File
@@ -2,11 +2,12 @@
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\GameController; use App\Http\Controllers\GameController;
use App\Http\Controllers\UserController;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
# Authentication Routes
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () { Route::middleware('auth:sanctum')->group(function () {
@@ -16,4 +17,13 @@ Route::middleware('auth:sanctum')->group(function () {
Route::post('logout', [AuthController::class, 'logout']); Route::post('logout', [AuthController::class, 'logout']);
}); });
Route::apiResource('games', GameController::class); Route::post('/register', [AuthController::class, 'register']);
# Game Routes - Protected
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('games', GameController::class);
Route::post('games', [GameController::class, 'store']);
Route::post('/games/{game}/resign', [GameController::class, 'resign']);
Route::get('/users/{user}/matches', [UserController::class, 'getMatches']);
Route::post('games/{game}/join', [GameController::class, 'join']);
});
+8
View File
@@ -0,0 +1,8 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { toast } from 'vue-sonner'
import { useApiStore } from './api'
export const useGameStore = defineStore('game', () => {
})
+1
View File
@@ -0,0 +1 @@
PORT=
+43 -2
View File
@@ -1,5 +1,46 @@
import { addUser, removeUser } from "../state/connection";
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const HEARTBEAT_TIMEOUT = 10000; // 10 seconds
export const handleConnectionEvents = (io, socket) => { export const handleConnectionEvents = (io, socket) => {
socket.on("echo", (msg) => { let heartbeatInterval;
socket.emit("echo", msg); let heartbeatTimeout;
const startHeartbeat = () => {
heartbeatInterval = setInterval(() => {
socket.emit("heartbeat");
heartbeatTimeout = setTimeout(() => {
console.log(
`[Heartbeat] No response from ${socket.id}, disconnecting...`
);
socket.disconnect(true);
}, HEARTBEAT_TIMEOUT);
}, HEARTBEAT_INTERVAL);
};
socket.on("heartbeat_ack", () => {
clearTimeout(heartbeatTimeout);
console.log(`[Heartbeat] Received pong from ${socket.id}`);
});
socket.on("join", (user) => {
addUser(socket, user);
console.log(`[Connection] User ${user.name} has joined the server`);
console.log(`[Connection] ${getUserCount()} users online`);
});
socket.on("leave", () => {
const user = removeUser(socket.id);
if (user) {
console.log(`[Connection] User ${user.name} has left the server`);
console.log(`[Connection] ${getUserCount()} users online`);
}
});
socket.on("disconnect", () => {
console.log("Connection Lost:", socket.id);
removeUser(socket.id);
console.log(`[Connection] ${getUserCount()} users online`);
}); });
}; };
+19
View File
@@ -0,0 +1,19 @@
const users = new Map();
export const addUser = (socket, user) => {
users.set(socket.id, user);
};
export const removeUser = (socketID) => {
const userToDelete = { ...users.get(socketID) };
users.delete(socketID);
return userToDelete;
};
export const getUser = (socketID) => {
return users.get(socketID);
};
export const getUserCount = () => {
return users.size;
};