diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0c5b4..43909b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2026-01-12 +- Switched main SPA layouts to fluid containers to reduce wasted space. +- Added username-or-email login with case-insensitive unique usernames. +- Added SPA-friendly verification and password reset/update endpoints. +- Added user avatars (upload + display) and a basic profile page/API. +- Seeded a Micha test user with verified email. + ## 2026-01-02 - Added ACP general settings for forum name, theme, accents, and logo (no reload required). - Added admin-only upload endpoints and ACP UI for logos and favicons. diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 52e046a..26d8c1f 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -3,6 +3,7 @@ namespace App\Actions\Fortify; use App\Models\User; +use Illuminate\Support\Str; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -19,8 +20,16 @@ class CreateNewUser implements CreatesNewUsers */ public function create(array $input): User { + $input['name_canonical'] = Str::lower(trim($input['name'] ?? '')); + Validator::make(data: $input, rules: [ 'name' => ['required', 'string', 'max:255'], + 'name_canonical' => [ + 'required', + 'string', + 'max:255', + Rule::unique(table: User::class, column: 'name_canonical'), + ], 'email' => [ 'required', 'string', @@ -33,6 +42,7 @@ class CreateNewUser implements CreatesNewUsers return User::create(attributes: [ 'name' => $input['name'], + 'name_canonical' => $input['name_canonical'], 'email' => $input['email'], 'password' => Hash::make(value: $input['password']), ]); diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index ba1e886..cc28a47 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -4,6 +4,7 @@ namespace App\Actions\Fortify; use App\Models\User; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Support\Str; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Laravel\Fortify\Contracts\UpdatesUserProfileInformation; @@ -17,8 +18,16 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation */ public function update(User $user, array $input): void { + $input['name_canonical'] = Str::lower(trim($input['name'] ?? '')); + Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], + 'name_canonical' => [ + 'required', + 'string', + 'max:255', + Rule::unique('users', 'name_canonical')->ignore($user->id), + ], 'email' => [ 'required', @@ -34,6 +43,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation } else { $user->forceFill([ 'name' => $input['name'], + 'name_canonical' => $input['name_canonical'], 'email' => $input['email'], ])->save(); } @@ -48,6 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation { $user->forceFill([ 'name' => $input['name'], + 'name_canonical' => $input['name_canonical'], 'email' => $input['email'], 'email_verified_at' => null, ])->save(); diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index ad3698c..dadb599 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -3,14 +3,22 @@ namespace App\Http\Controllers; use App\Actions\Fortify\CreateNewUser; +use App\Actions\Fortify\PasswordValidationRules; use App\Models\User; +use Illuminate\Auth\Events\Verified; +use Illuminate\Auth\Events\PasswordReset; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Password; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; class AuthController extends Controller { + use PasswordValidationRules; + public function register(Request $request, CreateNewUser $creator): JsonResponse { $input = [ @@ -33,16 +41,30 @@ class AuthController extends Controller public function login(Request $request): JsonResponse { + $request->merge([ + 'login' => $request->input('login', $request->input('email')), + ]); + $request->validate([ - 'email' => ['required', 'email'], + 'login' => ['required', 'string'], 'password' => ['required', 'string'], ]); - $user = User::where('email', $request->input('email'))->first(); + $login = trim((string) $request->input('login')); + $loginNormalized = Str::lower($login); + $userQuery = User::query(); + + if (filter_var($login, FILTER_VALIDATE_EMAIL)) { + $userQuery->whereRaw('lower(email) = ?', [$loginNormalized]); + } else { + $userQuery->where('name_canonical', $loginNormalized); + } + + $user = $userQuery->first(); if (!$user || !Hash::check($request->input('password'), $user->password)) { throw ValidationException::withMessages([ - 'email' => ['Invalid credentials.'], + 'login' => ['Invalid credentials.'], ]); } @@ -62,6 +84,93 @@ class AuthController extends Controller ]); } + public function verifyEmail(Request $request, string $id, string $hash): RedirectResponse + { + $user = User::findOrFail($id); + + if (!hash_equals($hash, sha1($user->getEmailForVerification()))) { + abort(403); + } + + if (!$user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + event(new Verified($user)); + } + + return redirect('/login'); + } + + public function forgotPassword(Request $request): JsonResponse + { + $request->validate([ + 'email' => ['required', 'email'], + ]); + + $status = Password::sendResetLink( + $request->only('email') + ); + + if ($status !== Password::RESET_LINK_SENT) { + throw ValidationException::withMessages([ + 'email' => [__($status)], + ]); + } + + return response()->json(['message' => __($status)]); + } + + public function resetPassword(Request $request): JsonResponse + { + $request->validate([ + 'token' => ['required'], + 'email' => ['required', 'email'], + 'password' => $this->passwordRules(), + ]); + + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function (User $user, string $password) { + $user->forceFill([ + 'password' => Hash::make($password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + if ($status !== Password::PASSWORD_RESET) { + throw ValidationException::withMessages([ + 'email' => [__($status)], + ]); + } + + return response()->json(['message' => __($status)]); + } + + public function updatePassword(Request $request): JsonResponse + { + $request->validate([ + 'current_password' => ['required'], + 'password' => $this->passwordRules(), + ]); + + $user = $request->user(); + + if (!$user || !Hash::check($request->input('current_password'), $user->password)) { + throw ValidationException::withMessages([ + 'current_password' => ['Invalid current password.'], + ]); + } + + $user->forceFill([ + 'password' => Hash::make($request->input('password')), + 'remember_token' => Str::random(60), + ])->save(); + + return response()->json(['message' => 'Password updated.']); + } + public function logout(Request $request): JsonResponse { $request->user()?->currentAccessToken()?->delete(); diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 74467c9..374c208 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -6,6 +6,7 @@ use App\Models\Post; use App\Models\Thread; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; class PostController extends Controller { @@ -84,6 +85,9 @@ class PostController extends Controller 'thread' => "/api/threads/{$post->thread_id}", 'user_id' => $post->user_id, 'user_name' => $post->user?->name, + 'user_avatar_url' => $post->user?->avatar_path + ? Storage::url($post->user->avatar_path) + : null, 'created_at' => $post->created_at?->toIso8601String(), 'updated_at' => $post->updated_at?->toIso8601String(), ]; diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php index bff746d..b7b51d5 100644 --- a/app/Http/Controllers/ThreadController.php +++ b/app/Http/Controllers/ThreadController.php @@ -6,6 +6,7 @@ use App\Models\Forum; use App\Models\Thread; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; class ThreadController extends Controller { @@ -95,6 +96,9 @@ class ThreadController extends Controller 'forum' => "/api/forums/{$thread->forum_id}", 'user_id' => $thread->user_id, 'user_name' => $thread->user?->name, + 'user_avatar_url' => $thread->user?->avatar_path + ? Storage::url($thread->user->avatar_path) + : null, 'created_at' => $thread->created_at?->toIso8601String(), 'updated_at' => $thread->updated_at?->toIso8601String(), ]; diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 6be801f..a335fba 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -8,6 +8,31 @@ use Illuminate\Support\Facades\Storage; class UploadController extends Controller { + public function storeAvatar(Request $request): JsonResponse + { + $user = $request->user(); + if (!$user) { + return response()->json(['message' => 'Unauthorized'], 401); + } + + $data = $request->validate([ + 'file' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:2048'], + ]); + + if ($user->avatar_path) { + Storage::disk('public')->delete($user->avatar_path); + } + + $path = $data['file']->store('avatars', 'public'); + $user->avatar_path = $path; + $user->save(); + + return response()->json([ + 'path' => $path, + 'url' => Storage::url($path), + ]); + } + public function storeLogo(Request $request): JsonResponse { $user = $request->user(); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 7933cf1..0a69cfb 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers; use App\Models\User; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; class UserController extends Controller { @@ -17,9 +19,46 @@ class UserController extends Controller 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, + 'avatar_url' => $this->resolveAvatarUrl($user), 'roles' => $user->roles->pluck('name')->values(), ]); return response()->json($users); } + + public function me(Request $request): JsonResponse + { + $user = $request->user(); + + if (!$user) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + return response()->json([ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'avatar_url' => $this->resolveAvatarUrl($user), + 'roles' => $user->roles()->pluck('name')->values(), + ]); + } + + public function profile(User $user): JsonResponse + { + return response()->json([ + 'id' => $user->id, + 'name' => $user->name, + 'avatar_url' => $this->resolveAvatarUrl($user), + 'created_at' => $user->created_at?->toIso8601String(), + ]); + } + + private function resolveAvatarUrl(User $user): ?string + { + if (!$user->avatar_path) { + return null; + } + + return Storage::url($user->avatar_path); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 6626854..f2de4ff 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -64,6 +64,8 @@ class User extends Authenticatable implements MustVerifyEmail */ protected $fillable = [ 'name', + 'name_canonical', + 'avatar_path', 'email', 'password', ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..53182b9 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -23,8 +23,11 @@ class UserFactory extends Factory */ public function definition(): array { + $name = fake()->unique()->userName(); + return [ - 'name' => fake()->name(), + 'name' => $name, + 'name_canonical' => Str::lower($name), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), diff --git a/database/migrations/2026_01_05_000000_add_name_canonical_to_users_table.php b/database/migrations/2026_01_05_000000_add_name_canonical_to_users_table.php new file mode 100644 index 0000000..6b72833 --- /dev/null +++ b/database/migrations/2026_01_05_000000_add_name_canonical_to_users_table.php @@ -0,0 +1,38 @@ +string('name_canonical')->nullable()->after('name'); + }); + + DB::table('users') + ->whereNull('name_canonical') + ->update(['name_canonical' => DB::raw('lower(name)')]); + + Schema::table('users', function (Blueprint $table) { + $table->unique('name_canonical'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropUnique(['name_canonical']); + $table->dropColumn('name_canonical'); + }); + } +}; diff --git a/database/migrations/2026_01_05_010000_add_avatar_path_to_users_table.php b/database/migrations/2026_01_05_010000_add_avatar_path_to_users_table.php new file mode 100644 index 0000000..cc5fa01 --- /dev/null +++ b/database/migrations/2026_01_05_010000_add_avatar_path_to_users_table.php @@ -0,0 +1,28 @@ +string('avatar_path')->nullable()->after('name_canonical'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('avatar_path'); + }); + } +}; diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 3347afb..838c8b4 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -4,6 +4,7 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; use App\Models\Role; use App\Models\User; @@ -14,14 +15,25 @@ class UserSeeder extends Seeder */ public function run(): void { - $adminRole = Role::where('name', 'ROLE_ADMIN')->first(); - $userRole = Role::where('name', 'ROLE_USER')->first(); + $adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first(); + $userRole = Role::where(column: 'name', operator: 'ROLE_USER')->first(); - $admin = User::firstOrCreate( - ['email' => 'tracer@24unix.net'], - [ + $admin = User::updateOrCreate( + attributes: ['email' => 'tracer@24unix.net'], + values : [ 'name' => 'tracer', - 'password' => Hash::make('password'), + 'name_canonical' => Str::lower('tracer'), + 'password' => Hash::make(value: 'password'), + 'email_verified_at' => now(), + ] + ); + + $micha = User::updateOrCreate( + attributes: ['email' => 'micha@24unix.net'], + values : [ + 'name' => 'Micha', + 'name_canonical' => Str::lower('Micha'), + 'password' => Hash::make(value: 'password'), 'email_verified_at' => now(), ] ); @@ -34,6 +46,10 @@ class UserSeeder extends Seeder $admin->roles()->syncWithoutDetaching([$userRole->id]); } + if ($userRole) { + $micha->roles()->syncWithoutDetaching([$userRole->id]); + } + $users = User::factory()->count(100)->create([ 'email_verified_at' => now(), ]); diff --git a/resources/js/App.jsx b/resources/js/App.jsx index 1348665..d2087d3 100644 --- a/resources/js/App.jsx +++ b/resources/js/App.jsx @@ -10,10 +10,19 @@ import Register from './pages/Register' import Acp from './pages/Acp' import BoardIndex from './pages/BoardIndex' import Ucp from './pages/Ucp' +import Profile from './pages/Profile' import { useTranslation } from 'react-i18next' import { fetchSettings, fetchVersion, getForum, getThread } from './api/client' -function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeaderName }) { +function PortalHeader({ + userMenu, + isAuthenticated, + forumName, + logoUrl, + showHeaderName, + canAccessAcp, + canAccessMcp, +}) { const { t } = useTranslation() const location = useLocation() const [crumbs, setCrumbs] = useState([]) @@ -107,7 +116,7 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade }, [location.pathname, t]) return ( - +
{logoUrl && ( @@ -135,12 +144,18 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade