2 Commits

Author SHA1 Message Date
0b4e0df305 Add ping endpoint, update-refresh prompt, and dark-mode polish
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 26s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 18:48:54 +01:00
2a69ee8258 Add functional forgot-password flow and login modal UX updates 2026-02-24 17:59:51 +01:00
11 changed files with 379 additions and 19 deletions

View File

@@ -1,5 +1,19 @@
# Changelog # Changelog
## 2026-02-24
- Added login modal actions: `Cancel` button and accent-styled, right-aligned `Sign in` button.
- Added functional `Forgot password?` flow with dedicated SPA route/page at `/reset-password`.
- Implemented reset-link request UI wired to `POST /api/forgot-password`.
- Implemented token-based new-password submission (`?token=...&email=...`) wired to `POST /api/reset-password`.
- Updated reset flow UX to return to `/login` after successful reset-link request and after successful password update.
- Added English and German translations for password reset screens/messages.
- Added new `/ping` endpoint returning connection status, build status, and notification state.
- Added frontend ping polling with active/hidden intervals and console diagnostics.
- Added update-available detection comparing loaded build vs ping build, with footer refresh CTA.
- Added update info modal prompting users to refresh when a newer build is detected.
- Tuned global dark mode palette to reduce overly bright text/surfaces in dark theme.
- Refined accent button state styling (hover/active/focus) to avoid Bootstrap blue fallback and preserve contrast.
## 2026-02-18 ## 2026-02-18
- Added CLI default PHP version detection to system status (`php_default_version`) using the CLI `php` binary. - Added CLI default PHP version detection to system status (`php_default_version`) using the CLI `php` binary.
- Updated ACP System -> CLI to show the CLI default PHP path/version in the panel header with sufficiency indicator and warning tooltip. - Updated ACP System -> CLI to show the CLI default PHP path/version in the panel header with sufficiency indicator and warning tooltip.

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
class PingController extends Controller
{
public function __invoke(): JsonResponse
{
$build = Setting::query()->where('key', 'build')->value('value');
$reportedBuild = $build !== null ? ((int) $build) + 1 : 1;
return response()->json([
'connect' => 'ok',
'version_status' => [
'build' => $reportedBuild,
],
'notification_state' => false,
]);
}
}

View File

@@ -1,18 +1,19 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom' import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom'
import { Container, NavDropdown } from 'react-bootstrap' import { Button, Container, Modal, NavDropdown } from 'react-bootstrap'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import Home from './pages/Home' import Home from './pages/Home'
import ForumView from './pages/ForumView' import ForumView from './pages/ForumView'
import ThreadView from './pages/ThreadView' import ThreadView from './pages/ThreadView'
import Login from './pages/Login' import Login from './pages/Login'
import Register from './pages/Register' import Register from './pages/Register'
import ResetPassword from './pages/ResetPassword'
import { Acp } from './pages/Acp' import { Acp } from './pages/Acp'
import BoardIndex from './pages/BoardIndex' import BoardIndex from './pages/BoardIndex'
import Ucp from './pages/Ucp' import Ucp from './pages/Ucp'
import Profile from './pages/Profile' import Profile from './pages/Profile'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client' import { fetchPing, fetchSettings, fetchVersion, getForum, getThread } from './api/client'
function PortalHeader({ function PortalHeader({
userMenu, userMenu,
@@ -240,9 +241,16 @@ function PortalHeader({
} }
function AppShell() { function AppShell() {
const PING_INTERVAL_MS = 15000
const PING_INTERVAL_HIDDEN_MS = 60000
const { t } = useTranslation() const { t } = useTranslation()
const { token, email, userId, logout, isAdmin, isModerator } = useAuth() const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
const [versionInfo, setVersionInfo] = useState(null) const [versionInfo, setVersionInfo] = useState(null)
const [availableBuild, setAvailableBuild] = useState(null)
const [pingBuild, setPingBuild] = useState(null)
const [showUpdateModal, setShowUpdateModal] = useState(false)
const currentBuildRef = useRef(null)
const promptedBuildRef = useRef(null)
const [theme, setTheme] = useState('auto') const [theme, setTheme] = useState('auto')
const [resolvedTheme, setResolvedTheme] = useState('light') const [resolvedTheme, setResolvedTheme] = useState('light')
const [accentOverride, setAccentOverride] = useState( const [accentOverride, setAccentOverride] = useState(
@@ -271,6 +279,73 @@ function AppShell() {
.catch(() => setVersionInfo(null)) .catch(() => setVersionInfo(null))
}, []) }, [])
useEffect(() => {
currentBuildRef.current =
typeof versionInfo?.build === 'number' ? versionInfo.build : null
}, [versionInfo?.build])
useEffect(() => {
const currentBuild =
typeof versionInfo?.build === 'number' ? versionInfo.build : null
if (currentBuild !== null && pingBuild !== null && pingBuild > currentBuild) {
setAvailableBuild(pingBuild)
return
}
setAvailableBuild(null)
}, [versionInfo?.build, pingBuild])
useEffect(() => {
if (availableBuild === null) return
if (promptedBuildRef.current === availableBuild) return
promptedBuildRef.current = availableBuild
setShowUpdateModal(true)
}, [availableBuild])
useEffect(() => {
let active = true
let timeoutId = null
const scheduleNext = () => {
const delay = document.hidden ? PING_INTERVAL_HIDDEN_MS : PING_INTERVAL_MS
timeoutId = window.setTimeout(runPing, delay)
}
const runPing = async () => {
try {
const data = await fetchPing()
const currentBuild = currentBuildRef.current
const remoteBuild =
typeof data?.version_status?.build === 'number'
? data.version_status.build
: null
console.log('speedBB ping', {
...data,
current_version: currentBuild,
})
if (!active) return
if (remoteBuild !== null) {
setPingBuild(remoteBuild)
}
window.dispatchEvent(new CustomEvent('speedbb-ping', { detail: data }))
} catch {
// ignore transient ping failures
} finally {
if (active) {
scheduleNext()
}
}
}
runPing()
return () => {
active = false
if (timeoutId) {
window.clearTimeout(timeoutId)
}
}
}, [])
useEffect(() => { useEffect(() => {
let active = true let active = true
const loadSettings = async () => { const loadSettings = async () => {
@@ -466,6 +541,7 @@ function AppShell() {
<Route path="/forum/:id" element={<ForumView />} /> <Route path="/forum/:id" element={<ForumView />} />
<Route path="/thread/:id" element={<ThreadView />} /> <Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/profile/:id" element={<Profile />} /> <Route path="/profile/:id" element={<Profile />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} /> <Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
@@ -493,8 +569,35 @@ function AppShell() {
<span className="bb-version-label">)</span> <span className="bb-version-label">)</span>
</span> </span>
)} )}
{availableBuild !== null && (
<Button
type="button"
size="sm"
className="bb-accent-button"
onClick={() => window.location.reload()}
>
{t('version.update_available_short')} (build {availableBuild}) ·{' '}
{t('version.update_now')}
</Button>
)}
</div> </div>
</footer> </footer>
<Modal show={showUpdateModal} onHide={() => setShowUpdateModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>{t('version.refresh_prompt_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{t('version.refresh_prompt_body', { build: availableBuild ?? '-' })}
</Modal.Body>
<Modal.Footer className="justify-content-between">
<Button variant="outline-secondary" onClick={() => setShowUpdateModal(false)}>
{t('version.remind_later')}
</Button>
<Button className="bb-accent-button" onClick={() => window.location.reload()}>
{t('version.update_now')}
</Button>
</Modal.Footer>
</Modal>
</div> </div>
) )
} }

View File

@@ -62,6 +62,20 @@ export async function registerUser({ email, username, plainPassword }) {
}) })
} }
export async function requestPasswordReset(email) {
return apiFetch('/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
})
}
export async function resetPassword({ token, email, password, password_confirmation }) {
return apiFetch('/reset-password', {
method: 'POST',
body: JSON.stringify({ token, email, password, password_confirmation }),
})
}
export async function logoutUser() { export async function logoutUser() {
return apiFetch('/logout', { return apiFetch('/logout', {
method: 'POST', method: 'POST',
@@ -115,6 +129,15 @@ export async function fetchVersion() {
return apiFetch('/version') return apiFetch('/version')
} }
export async function fetchPing() {
const response = await fetch('/ping', {
headers: {
Accept: 'application/json',
},
})
return parseResponse(response)
}
export async function fetchVersionCheck() { export async function fetchVersionCheck() {
return apiFetch('/version/check') return apiFetch('/version/check')
} }

View File

@@ -862,10 +862,17 @@ a {
} }
[data-bs-theme="dark"] { [data-bs-theme="dark"] {
--bb-ink: #aaaeb4; --bb-ink: #a3acb9;
--bb-ink-muted: #6b7483; --bb-ink-muted: #626d7e;
--bb-border: #2a2f3a; --bb-border: #242a35;
--bb-page-bg: radial-gradient(circle at 10% 20%, #141823 0%, #10131a 45%, #0b0e14 100%); --bb-page-bg: radial-gradient(circle at 10% 20%, #10151f 0%, #0b1018 45%, #080b11 100%);
--bs-body-bg: #080b11;
--bs-body-color: #b3bdca;
--bs-secondary-bg: #121822;
--bs-tertiary-bg: #0f141d;
--bs-border-color: #242a35;
--bs-modal-bg: #121822;
--bs-modal-color: #b3bdca;
} }
[data-bs-theme="dark"] .bb-hero { [data-bs-theme="dark"] .bb-hero {
@@ -875,11 +882,30 @@ a {
[data-bs-theme="dark"] .bb-card, [data-bs-theme="dark"] .bb-card,
[data-bs-theme="dark"] .bb-form { [data-bs-theme="dark"] .bb-form {
background: #171b22; background: #121822;
border-color: #2a2f3a; border-color: #242a35;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
} }
[data-bs-theme="dark"] .modal-content {
background: #121822;
border-color: #242a35;
color: #b3bdca;
}
[data-bs-theme="dark"] .modal-content .modal-body,
[data-bs-theme="dark"] .modal-content .modal-footer {
background: #121822;
}
[data-bs-theme="dark"] .modal-content .modal-header {
color: #b7c0cc;
}
[data-bs-theme="dark"] .modal-content .modal-title {
color: #b7c0cc;
}
[data-bs-theme="light"] .bb-forum-row { [data-bs-theme="light"] .bb-forum-row {
background: #fff; background: #fff;
} }
@@ -2172,16 +2198,25 @@ a {
} }
.bb-accent-button { .bb-accent-button {
background: var(--bb-accent, #f29b3f); --bs-btn-bg: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f); --bs-btn-border-color: var(--bb-accent, #f29b3f);
color: #0e121b; --bs-btn-color: #0e121b;
--bs-btn-hover-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
--bs-btn-hover-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
--bs-btn-hover-color: #fff;
--bs-btn-active-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000);
--bs-btn-active-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000);
--bs-btn-active-color: #fff;
--bs-btn-disabled-bg: var(--bb-accent, #f29b3f);
--bs-btn-disabled-border-color: var(--bb-accent, #f29b3f);
--bs-btn-disabled-color: #0e121b;
background: var(--bs-btn-bg);
border-color: var(--bs-btn-border-color);
color: var(--bs-btn-color);
} }
.bb-accent-button:hover, .bb-accent-button:focus-visible {
.bb-accent-button:focus { box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 35%, transparent);
background: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
color: #0e121b;
} }
.bb-accent-button:disabled, .bb-accent-button:disabled,

View File

@@ -57,11 +57,11 @@ export default function Login() {
<Link to="/reset-password">{t('auth.forgot_password')}</Link> <Link to="/reset-password">{t('auth.forgot_password')}</Link>
</div> </div>
</Form.Group> </Form.Group>
<div className="d-flex gap-2"> <div className="d-flex w-100 align-items-center gap-2">
<Button as={Link} to="/" type="button" variant="outline-secondary" disabled={loading}> <Button as={Link} to="/" type="button" variant="outline-secondary" disabled={loading}>
{t('acp.cancel')} {t('acp.cancel')}
</Button> </Button>
<Button type="submit" variant="dark" disabled={loading}> <Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
{loading ? t('form.signing_in') : t('form.sign_in')} {loading ? t('form.signing_in') : t('form.sign_in')}
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import { Button, Card, Container, Form } from 'react-bootstrap'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { requestPasswordReset, resetPassword } from '../api/client'
export default function ResetPassword() {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const token = searchParams.get('token') || ''
const emailFromLink = searchParams.get('email') || ''
const isResetFlow = token.length > 0
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirmation, setPasswordConfirmation] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
if (emailFromLink) {
setEmail(emailFromLink)
}
}, [emailFromLink])
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
setLoading(true)
try {
if (isResetFlow) {
await resetPassword({
token,
email,
password,
password_confirmation: passwordConfirmation,
})
navigate('/login')
} else {
await requestPasswordReset(email)
navigate('/login')
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
<Card.Body>
<Card.Title className="mb-3">
{isResetFlow ? t('auth.reset_password_title') : t('auth.forgot_password')}
</Card.Title>
<Card.Text className="bb-muted">
{isResetFlow ? t('auth.reset_password_hint') : t('auth.forgot_password_hint')}
</Card.Text>
{error && <p className="text-danger">{error}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-4">
<Form.Label>{t('form.email')}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder={t('auth.reset_email_placeholder')}
required
/>
</Form.Group>
{isResetFlow && (
<>
<Form.Group className="mb-3">
<Form.Label>{t('form.password')}</Form.Label>
<Form.Control
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-4">
<Form.Label>{t('auth.confirm_password')}</Form.Label>
<Form.Control
type="password"
value={passwordConfirmation}
onChange={(event) => setPasswordConfirmation(event.target.value)}
required
/>
</Form.Group>
</>
)}
<div className="d-flex w-100 align-items-center gap-2">
<Button as={Link} to="/login" type="button" variant="outline-secondary" disabled={loading}>
{t('acp.cancel')}
</Button>
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
{loading
? isResetFlow
? t('auth.resetting_password')
: t('auth.sending_reset_link')
: isResetFlow
? t('auth.reset_password_submit')
: t('auth.send_reset_link')}
</Button>
</div>
</Form>
</Card.Body>
</Card>
</Container>
)
}

View File

@@ -82,6 +82,17 @@
"auth.login_identifier": "E-Mail oder Benutzername", "auth.login_identifier": "E-Mail oder Benutzername",
"auth.login_placeholder": "name@example.com oder benutzername", "auth.login_placeholder": "name@example.com oder benutzername",
"auth.forgot_password": "Passwort vergessen?", "auth.forgot_password": "Passwort vergessen?",
"auth.forgot_password_hint": "Gib die E-Mail-Adresse deines Kontos ein, dann senden wir dir einen Link zum Zuruecksetzen.",
"auth.reset_email_placeholder": "name@example.com",
"auth.send_reset_link": "Reset-Link senden",
"auth.sending_reset_link": "Wird gesendet...",
"auth.reset_link_sent": "Falls ein Konto existiert, wurde ein Passwort-Reset-Link gesendet.",
"auth.reset_password_title": "Passwort zuruecksetzen",
"auth.reset_password_hint": "Gib deine E-Mail-Adresse ein und waehle ein neues Passwort.",
"auth.reset_password_submit": "Passwort zuruecksetzen",
"auth.resetting_password": "Wird zurueckgesetzt...",
"auth.password_reset_success": "Passwort erfolgreich zurueckgesetzt. Du kannst dich jetzt anmelden.",
"auth.confirm_password": "Passwort bestaetigen",
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.", "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.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
"auth.register_title": "Konto erstellen", "auth.register_title": "Konto erstellen",
@@ -175,6 +186,9 @@
"version.up_to_date": "Aktuell", "version.up_to_date": "Aktuell",
"version.update_available": "Update verfügbar (v{{version}})", "version.update_available": "Update verfügbar (v{{version}})",
"version.update_available_short": "Update verfügbar", "version.update_available_short": "Update verfügbar",
"version.refresh_prompt_title": "Update verfuegbar",
"version.refresh_prompt_body": "Ein neuerer Build ({{build}}) ist verfuegbar. Jetzt neu laden, um die aktuelle Version zu verwenden?",
"version.remind_later": "Spaeter",
"version.unknown": "Version unbekannt", "version.unknown": "Version unbekannt",
"version.update_now": "Jetzt aktualisieren", "version.update_now": "Jetzt aktualisieren",
"version.update_title": "System aktualisieren", "version.update_title": "System aktualisieren",

View File

@@ -82,6 +82,17 @@
"auth.login_identifier": "Email or username", "auth.login_identifier": "Email or username",
"auth.login_placeholder": "name@example.com or username", "auth.login_placeholder": "name@example.com or username",
"auth.forgot_password": "Forgot password?", "auth.forgot_password": "Forgot password?",
"auth.forgot_password_hint": "Enter your account email and we will send you a password reset link.",
"auth.reset_email_placeholder": "name@example.com",
"auth.send_reset_link": "Send reset link",
"auth.sending_reset_link": "Sending...",
"auth.reset_link_sent": "If an account exists, a password reset link has been sent.",
"auth.reset_password_title": "Reset password",
"auth.reset_password_hint": "Enter your email and choose a new password.",
"auth.reset_password_submit": "Reset password",
"auth.resetting_password": "Resetting...",
"auth.password_reset_success": "Password reset successful. You can now sign in.",
"auth.confirm_password": "Confirm password",
"auth.register_hint": "Register with an email and a unique 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.verify_notice": "Check your email to verify your account before logging in.",
"auth.register_title": "Create account", "auth.register_title": "Create account",
@@ -165,6 +176,9 @@
"version.up_to_date": "Up to date", "version.up_to_date": "Up to date",
"version.update_available": "Update available (v{{version}})", "version.update_available": "Update available (v{{version}})",
"version.update_available_short": "Update available", "version.update_available_short": "Update available",
"version.refresh_prompt_title": "Update available",
"version.refresh_prompt_body": "A newer build ({{build}}) is available. Refresh now to load the latest version?",
"version.remind_later": "Later",
"version.unknown": "Version unknown", "version.unknown": "Version unknown",
"version.update_now": "Update now", "version.update_now": "Update now",
"version.update_title": "Update system", "version.update_title": "Update system",

View File

@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\InstallerController; use App\Http\Controllers\InstallerController;
use App\Http\Controllers\PingController;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -29,6 +30,8 @@ Route::get('/reset-password', function () {
return view('app'); return view('app');
})->name('password.reset'); })->name('password.reset');
Route::get('/ping', PingController::class);
Route::get('/{any}', function () { Route::get('/{any}', function () {
if (!file_exists(base_path('.env'))) { if (!file_exists(base_path('.env'))) {
return redirect('/install'); return redirect('/install');

View File

@@ -0,0 +1,18 @@
<?php
use App\Models\Setting;
it('returns ping status with build and notification state', function (): void {
Setting::updateOrCreate(['key' => 'build'], ['value' => '1337']);
$response = $this->getJson('/ping');
$response->assertOk();
$response->assertJson([
'connect' => 'ok',
'version_status' => [
'build' => 1337,
],
'notification_state' => false,
]);
});