Add avatars, profiles, and auth flows

This commit is contained in:
Micha
2026-01-12 23:40:11 +01:00
parent bbbf8eb6c1
commit 3bb2946656
30 changed files with 691 additions and 48 deletions

View File

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

View File

@@ -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']),
]);

View File

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

View File

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

View File

@@ -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(),
];

View File

@@ -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(),
];

View File

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

View File

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

View File

@@ -64,6 +64,8 @@ class User extends Authenticatable implements MustVerifyEmail
*/
protected $fillable = [
'name',
'name_canonical',
'avatar_path',
'email',
'password',
];

View File

@@ -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'),

View File

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

View File

@@ -0,0 +1,28 @@
<?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::table('users', function (Blueprint $table) {
$table->string('avatar_path')->nullable()->after('name_canonical');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('avatar_path');
});
}
};

View File

@@ -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(),
]);

View File

@@ -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 (
<Container className="pt-2 pb-2 bb-portal-shell">
<Container fluid className="pt-2 pb-2 bb-portal-shell">
<div className="bb-portal-banner">
<div className="bb-portal-brand">
{logoUrl && (
@@ -135,12 +144,18 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
<span>
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
</span>
<Link to="/acp" className="bb-portal-link">
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
</Link>
<span>
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
</span>
{isAuthenticated && canAccessAcp && (
<>
<Link to="/acp" className="bb-portal-link">
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
</Link>
</>
)}
{isAuthenticated && canAccessMcp && (
<span>
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
</span>
)}
</div>
</div>
<div
@@ -197,7 +212,7 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
function AppShell() {
const { t } = useTranslation()
const { token, email, logout, isAdmin } = useAuth()
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
const [versionInfo, setVersionInfo] = useState(null)
const [theme, setTheme] = useState('auto')
const [resolvedTheme, setResolvedTheme] = useState('light')
@@ -403,7 +418,7 @@ function AppShell() {
<NavDropdown.Item as={Link} to="/ucp">
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
</NavDropdown.Item>
<NavDropdown.Item as={Link} to="/ucp">
<NavDropdown.Item as={Link} to={`/profile/${userId ?? ''}`}>
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
</NavDropdown.Item>
<NavDropdown.Divider />
@@ -413,6 +428,8 @@ function AppShell() {
</NavDropdown>
) : null
}
canAccessAcp={isAdmin}
canAccessMcp={isModerator}
/>
<Routes>
<Route path="/" element={<Home />} />
@@ -421,6 +438,7 @@ function AppShell() {
<Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/profile/:id" element={<Profile />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
<Route
path="/ucp"

View File

@@ -48,10 +48,10 @@ export async function getCollection(path) {
return data?.['hydra:member'] || []
}
export async function login(email, password) {
export async function login(login, password) {
return apiFetch('/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
body: JSON.stringify({ login, password }),
})
}
@@ -70,6 +70,23 @@ export async function listAllForums() {
return getCollection('/forums?pagination=false')
}
export async function getCurrentUser() {
return apiFetch('/user/me')
}
export async function uploadAvatar(file) {
const body = new FormData()
body.append('file', file)
return apiFetch('/user/avatar', {
method: 'POST',
body,
})
}
export async function getUserProfile(id) {
return apiFetch(`/user/profile/${id}`)
}
export async function fetchVersion() {
return apiFetch('/version')
}

View File

@@ -27,10 +27,11 @@ export function AuthProvider({ children }) {
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
async login(emailInput, password) {
const data = await apiLogin(emailInput, password)
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
async login(loginInput, password) {
const data = await apiLogin(loginInput, password)
localStorage.setItem('speedbb_token', data.token)
localStorage.setItem('speedbb_email', data.email || emailInput)
localStorage.setItem('speedbb_email', data.email || loginInput)
if (data.user_id) {
localStorage.setItem('speedbb_user_id', String(data.user_id))
setUserId(String(data.user_id))
@@ -43,7 +44,7 @@ export function AuthProvider({ children }) {
setRoles([])
}
setToken(data.token)
setEmail(data.email || emailInput)
setEmail(data.email || loginInput)
},
logout() {
localStorage.removeItem('speedbb_token')
@@ -77,6 +78,7 @@ export function AuthProvider({ children }) {
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
hasToken: Boolean(token),
})
}, [email, effectiveUserId, effectiveRoles, token])

View File

@@ -7,6 +7,7 @@
--bb-gold: #e4a634;
--bb-peach: #f4c7a3;
--bb-border: #e0d7c7;
--bb-shell-max: 1880px;
}
* {
@@ -200,7 +201,7 @@ a {
.bb-post-row {
display: grid;
grid-template-columns: 220px 1fr;
grid-template-columns: 260px 1fr;
border-top: 1px solid var(--bb-border);
}
@@ -218,15 +219,22 @@ a {
}
.bb-post-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
width: 150px;
height: 150px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
color: var(--bb-accent, #f29b3f);
font-size: 1.1rem;
overflow: hidden;
}
.bb-post-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bb-post-author-name {
@@ -234,9 +242,62 @@ a {
color: var(--bb-accent, #f29b3f);
}
.bb-post-author-role {
color: var(--bb-ink-muted);
font-size: 0.9rem;
margin-top: -0.2rem;
}
.bb-post-author-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.15rem 0.6rem;
border-radius: 6px;
background: linear-gradient(135deg, #f4f4f4, #c9c9c9);
color: #7b1f2a;
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
width: fit-content;
}
.bb-post-author-meta {
font-size: 0.85rem;
color: var(--bb-ink-muted);
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.3rem;
}
.bb-post-author-stat {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.bb-post-author-label {
color: var(--bb-ink-muted);
font-weight: 600;
}
.bb-post-author-value {
color: var(--bb-ink);
}
.bb-post-author-value i {
color: var(--bb-accent, #f29b3f);
font-size: 1.05rem;
}
.bb-post-author-contact .bb-post-author-value {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.bb-post-content {
@@ -322,6 +383,11 @@ a {
flex-direction: row;
align-items: center;
}
.bb-post-avatar {
width: 80px;
height: 80px;
}
}
.bb-forum-row {
@@ -749,8 +815,9 @@ a {
font-weight: 600;
}
.bb-portal-shell {
max-width: 1400px;
.container.bb-portal-shell,
.container.bb-shell-container {
max-width: var(--bb-shell-max);
}
.bb-portal-banner {
@@ -1189,6 +1256,56 @@ a {
margin-bottom: 0.8rem;
}
.bb-avatar-preview {
width: 150px;
height: 150px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: var(--bb-accent, #f29b3f);
font-size: 2rem;
overflow: hidden;
}
.bb-avatar-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bb-profile {
display: flex;
gap: 1.2rem;
align-items: center;
}
.bb-profile-avatar {
width: 150px;
height: 150px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: var(--bb-accent, #f29b3f);
font-size: 2rem;
overflow: hidden;
}
.bb-profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bb-profile-name {
font-size: 1.4rem;
font-weight: 700;
color: var(--bb-ink);
}
.bb-portal-list {
list-style: none;
padding: 0;

View File

@@ -942,7 +942,7 @@ export default function Acp({ isAdmin }) {
if (!isAdmin) {
return (
<Container className="py-5">
<Container fluid className="py-5">
<h2 className="mb-3">{t('acp.title')}</h2>
<p className="bb-muted">{t('acp.no_access')}</p>
</Container>

View File

@@ -139,7 +139,7 @@ export default function BoardIndex() {
))
return (
<Container className="py-4 bb-portal-shell">
<Container fluid className="py-4 bb-portal-shell">
{loading && <p className="bb-muted">{t('home.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{!loading && forumTree.length === 0 && (

View File

@@ -96,7 +96,7 @@ export default function ForumView() {
}
return (
<Container className="py-5">
<Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{forum && (

View File

@@ -118,7 +118,7 @@ export default function Home() {
))
return (
<Container className="pb-4 bb-portal-shell">
<Container fluid className="pb-4 bb-portal-shell">
<div className="bb-portal-layout">
<aside className="bb-portal-column bb-portal-column--left">
<div className="bb-portal-card">

View File

@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
export default function Login() {
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [loginValue, setLoginValue] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
@@ -18,7 +18,7 @@ export default function Login() {
setError('')
setLoading(true)
try {
await login(email, password)
await login(loginValue, password)
navigate('/')
} catch (err) {
setError(err.message)
@@ -28,7 +28,7 @@ export default function Login() {
}
return (
<Container className="py-5">
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
<Card.Body>
<Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
@@ -36,11 +36,12 @@ export default function Login() {
{error && <p className="text-danger">{error}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.email')}</Form.Label>
<Form.Label>{t('auth.login_identifier')}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
type="text"
value={loginValue}
onChange={(event) => setLoginValue(event.target.value)}
placeholder={t('auth.login_placeholder')}
required
/>
</Form.Group>

View File

@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { Container } from 'react-bootstrap'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { getUserProfile } from '../api/client'
export default function Profile() {
const { id } = useParams()
const { t } = useTranslation()
const [profile, setProfile] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
setLoading(true)
setError('')
getUserProfile(id)
.then((data) => {
if (!active) return
setProfile(data)
})
.catch((err) => {
if (!active) return
setError(err.message)
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [id])
return (
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('profile.title')}</div>
{loading && <p className="bb-muted">{t('profile.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{profile && (
<div className="bb-profile">
<div className="bb-profile-avatar">
{profile.avatar_url ? (
<img src={profile.avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
<div className="bb-profile-meta">
<div className="bb-profile-name">{profile.name}</div>
{profile.created_at && (
<div className="bb-muted">
{t('profile.registered')} {profile.created_at.slice(0, 10)}
</div>
)}
</div>
</div>
)}
</div>
</Container>
)
}

View File

@@ -33,7 +33,7 @@ export default function Register() {
}
return (
<Container className="py-5">
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
<Card.Body>
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>

View File

@@ -52,6 +52,7 @@ export default function ThreadView() {
body: thread.body,
created_at: thread.created_at,
user_name: thread.user_name,
user_avatar_url: thread.user_avatar_url,
isRoot: true,
}
return [rootPost, ...posts]
@@ -64,7 +65,7 @@ export default function ThreadView() {
const totalPosts = allPosts.length
return (
<Container className="py-4">
<Container fluid className="py-4 bb-shell-container">
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{thread && (
@@ -116,11 +117,42 @@ export default function ThreadView() {
<article className="bb-post-row" key={post.id}>
<aside className="bb-post-author">
<div className="bb-post-avatar">
<i className="bi bi-person" aria-hidden="true" />
{post.user_avatar_url ? (
<img src={post.user_avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
<div className="bb-post-author-name">{authorName}</div>
<div className="bb-post-author-role">Operator</div>
<div className="bb-post-author-badge">TEAM-RHF</div>
<div className="bb-post-author-meta">
{post.isRoot ? t('thread.label') : t('thread.reply')}
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Posts:</span>
<span className="bb-post-author-value">63899</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Registered:</span>
<span className="bb-post-author-value">18.08.2004 18:50:03</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Location:</span>
<span className="bb-post-author-value">Kollmar</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks given:</span>
<span className="bb-post-author-value">7</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks received:</span>
<span className="bb-post-author-value">5</span>
</div>
<div className="bb-post-author-stat bb-post-author-contact">
<span className="bb-post-author-label">Contact:</span>
<span className="bb-post-author-value">
<i className="bi bi-chat-dots" aria-hidden="true" />
</span>
</div>
</div>
</aside>
<div className="bb-post-content">

View File

@@ -1,9 +1,34 @@
import { useEffect, useState } from 'react'
import { Container, Form, Row, Col } from 'react-bootstrap'
import { getCurrentUser, uploadAvatar } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
const { t, i18n } = useTranslation()
const { token } = useAuth()
const accentMode = accentOverride ? 'custom' : 'system'
const [avatarError, setAvatarError] = useState('')
const [avatarUploading, setAvatarUploading] = useState(false)
const [avatarPreview, setAvatarPreview] = useState('')
useEffect(() => {
if (!token) return
let active = true
getCurrentUser()
.then((data) => {
if (!active) return
setAvatarPreview(data?.avatar_url || '')
})
.catch(() => {
if (active) setAvatarPreview('')
})
return () => {
active = false
}
}, [token])
const handleLanguageChange = (event) => {
const locale = event.target.value
@@ -12,7 +37,48 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
}
return (
<Container className="py-5 bb-portal-shell">
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card mb-4">
<div className="bb-portal-card-title">{t('ucp.profile')}</div>
<p className="bb-muted mb-4">{t('ucp.profile_hint')}</p>
<Row className="g-3 align-items-center">
<Col md="auto">
<div className="bb-avatar-preview">
{avatarPreview ? (
<img src={avatarPreview} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
</Col>
<Col>
{avatarError && <p className="text-danger mb-2">{avatarError}</p>}
<Form.Group>
<Form.Label>{t('ucp.avatar_label')}</Form.Label>
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
disabled={!token || avatarUploading}
onChange={async (event) => {
const file = event.target.files?.[0]
if (!file) return
setAvatarError('')
setAvatarUploading(true)
try {
const response = await uploadAvatar(file)
setAvatarPreview(response.url)
} catch (err) {
setAvatarError(err.message)
} finally {
setAvatarUploading(false)
}
}}
/>
<Form.Text className="bb-muted">{t('ucp.avatar_hint')}</Form.Text>
</Form.Group>
</Col>
</Row>
</div>
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
<p className="bb-muted mb-4">{t('ucp.intro')}</p>

View File

@@ -60,6 +60,8 @@
"acp.users": "Benutzer",
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
"auth.login_title": "Anmelden",
"auth.login_identifier": "E-Mail oder Benutzername",
"auth.login_placeholder": "name@example.com oder benutzername",
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
"auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
"auth.register_title": "Konto erstellen",
@@ -149,7 +151,14 @@
"portal.user_profile": "Profil",
"portal.user_logout": "Logout",
"portal.advertisement": "Werbung",
"profile.title": "Profil",
"profile.loading": "Profil wird geladen...",
"profile.registered": "Registriert:",
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
"ucp.profile": "Profil",
"ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.",
"ucp.avatar_label": "Profilbild",
"ucp.avatar_hint": "Lade ein Bild hoch (max. 150x150px, Du kannst jpg, png, gif oder webp verwenden).",
"ucp.system_default": "Systemstandard",
"ucp.accent_override": "Akzentfarbe überschreiben",
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",

View File

@@ -60,6 +60,8 @@
"acp.users": "Users",
"auth.login_hint": "Access your account to start new threads and reply.",
"auth.login_title": "Log in",
"auth.login_identifier": "Email or username",
"auth.login_placeholder": "name@example.com or username",
"auth.register_hint": "Register with an email and a unique username.",
"auth.verify_notice": "Check your email to verify your account before logging in.",
"auth.register_title": "Create account",
@@ -149,7 +151,14 @@
"portal.user_profile": "Profile",
"portal.user_logout": "Logout",
"portal.advertisement": "Advertisement",
"profile.title": "Profile",
"profile.loading": "Loading profile...",
"profile.registered": "Registered:",
"ucp.intro": "Manage your basic preferences for the forum.",
"ucp.profile": "Profile",
"ucp.profile_hint": "Update the avatar shown next to your posts.",
"ucp.avatar_label": "Profile image",
"ucp.avatar_hint": "Upload an image (max 150x150px, you can use jpg, png, gif, or webp).",
"ucp.system_default": "System default",
"ucp.accent_override": "Accent color override",
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",

View File

@@ -14,7 +14,13 @@ use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->middleware('guest');
Route::post('/reset-password', [AuthController::class, 'resetPassword'])->middleware('guest');
Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
->middleware('signed')
->name('verification.verify');
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
Route::get('/version', VersionController::class);
Route::get('/settings', [SettingController::class, 'index']);
@@ -24,8 +30,11 @@ Route::get('/user-settings', [UserSettingController::class, 'index'])->middlewar
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->middleware('auth:sanctum');
Route::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middleware('auth:sanctum');
Route::get('/i18n/{locale}', I18nController::class);
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum');
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');
Route::get('/forums', [ForumController::class, 'index']);
Route::get('/forums/{forum}', [ForumController::class, 'show']);

View File

@@ -3,5 +3,7 @@
use Illuminate\Support\Facades\Route;
Route::view('/', 'app');
Route::view('/login', 'app')->name('login');
Route::view('/reset-password', 'app')->name('password.reset');
Route::view('/{any}', 'app')->where('any', '^(?!api).*$');