Compare commits
7 Commits
dev
...
16e0444fa3
| Author | SHA1 | Date | |
|---|---|---|---|
| 16e0444fa3 | |||
| 6a2316c6f4 | |||
| 0b4e0df305 | |||
| 2a69ee8258 | |||
| 1c2353cfe1 | |||
| 496b50ed12 | |||
| 50e3ff6ded |
@@ -6,22 +6,6 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
- master
|
- master
|
||||||
jobs:
|
jobs:
|
||||||
dev_tests:
|
|
||||||
if: gitea.ref_name == 'dev'
|
|
||||||
runs-on: debian-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
run: |
|
|
||||||
git clone --quiet --depth=1 --branch=${{ gitea.ref_name }} ${{ gitea.server_url }}/${{ gitea.repository }} repo
|
|
||||||
- name: Install Bats
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y bats
|
|
||||||
- name: Run Shell Tests
|
|
||||||
run: |
|
|
||||||
cd repo
|
|
||||||
bats tests/shell/git_update.bats
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
if: gitea.ref_name == 'master'
|
if: gitea.ref_name == 'master'
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
|
|||||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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.
|
||||||
|
|||||||
@@ -27,6 +27,19 @@
|
|||||||
state: directory
|
state: directory
|
||||||
mode: "0775"
|
mode: "0775"
|
||||||
|
|
||||||
|
- name: Ensure public storage directory exists
|
||||||
|
file:
|
||||||
|
path: "{{ prod_base_dir }}/storage/app/public"
|
||||||
|
state: directory
|
||||||
|
mode: "0775"
|
||||||
|
|
||||||
|
- name: Ensure public storage symlink exists
|
||||||
|
file:
|
||||||
|
src: "{{ prod_base_dir }}/storage/app/public"
|
||||||
|
dest: "{{ prod_base_dir }}/public/storage"
|
||||||
|
state: link
|
||||||
|
force: true
|
||||||
|
|
||||||
- name: Download and installs all libs and dependencies
|
- name: Download and installs all libs and dependencies
|
||||||
block:
|
block:
|
||||||
- name: Composer install
|
- name: Composer install
|
||||||
@@ -93,8 +106,8 @@
|
|||||||
msg: "Database backed up to: {{ backup_result.stdout }}"
|
msg: "Database backed up to: {{ backup_result.stdout }}"
|
||||||
when: env_file.stat.exists
|
when: env_file.stat.exists
|
||||||
|
|
||||||
- name: Run database migrations safely
|
- name: Run database migrations
|
||||||
command: "keyhelp-php84 artisan migrate:safe --force"
|
command: "keyhelp-php84 artisan migrate --force"
|
||||||
args:
|
args:
|
||||||
chdir: "{{ prod_base_dir }}"
|
chdir: "{{ prod_base_dir }}"
|
||||||
register: migrate_result
|
register: migrate_result
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ class VersionFetch extends Command
|
|||||||
{
|
{
|
||||||
protected $signature = 'version:fetch';
|
protected $signature = 'version:fetch';
|
||||||
|
|
||||||
protected $description = 'Update the build number based on the git commit count of master.';
|
protected $description = 'Sync version/build metadata into settings using composer.json version and git build count.';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$version = Setting::where('key', 'version')->value('value');
|
$version = $this->resolveComposerVersion();
|
||||||
$build = $this->resolveBuildCount();
|
$build = $this->resolveBuildCount();
|
||||||
|
|
||||||
if ($version === null) {
|
if ($version === null) {
|
||||||
$this->error('Unable to determine version from settings.');
|
$this->error('Unable to determine version from composer.json.');
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,21 +27,56 @@ class VersionFetch extends Command
|
|||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => 'version'],
|
||||||
|
['value' => $version],
|
||||||
|
);
|
||||||
|
|
||||||
Setting::updateOrCreate(
|
Setting::updateOrCreate(
|
||||||
['key' => 'build'],
|
['key' => 'build'],
|
||||||
['value' => (string) $build],
|
['value' => (string) $build],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$this->syncComposerMetadata($version, $build)) {
|
if (!$this->syncComposerBuild($build)) {
|
||||||
$this->error('Failed to sync version/build to composer.json.');
|
$this->error('Failed to sync version/build to composer.json.');
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Build number updated to {$build}.");
|
$this->info("Version/build synced: {$version} (build {$build}).");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveComposerVersion(): ?string
|
||||||
|
{
|
||||||
|
$composerPath = base_path('composer.json');
|
||||||
|
|
||||||
|
if (!is_file($composerPath) || !is_readable($composerPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents($composerPath);
|
||||||
|
if ($raw === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = trim((string) ($data['version'] ?? ''));
|
||||||
|
if ($version === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^\d+\.\d+(?:\.\d+)?(?:[-._][0-9A-Za-z.-]+)?$/', $version)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $version;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveBuildCount(): ?int
|
private function resolveBuildCount(): ?int
|
||||||
{
|
{
|
||||||
$commands = [
|
$commands = [
|
||||||
@@ -64,7 +99,7 @@ class VersionFetch extends Command
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncComposerMetadata(string $version, int $build): bool
|
private function syncComposerBuild(int $build): bool
|
||||||
{
|
{
|
||||||
$composerPath = base_path('composer.json');
|
$composerPath = base_path('composer.json');
|
||||||
|
|
||||||
@@ -82,7 +117,6 @@ class VersionFetch extends Command
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data['version'] = $version;
|
|
||||||
$data['build'] = (string) $build;
|
$data['build'] = (string) $build;
|
||||||
|
|
||||||
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|||||||
23
app/Http/Controllers/PingController.php
Normal file
23
app/Http/Controllers/PingController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,6 +209,10 @@ class UserController extends Controller
|
|||||||
->pluck('id')
|
->pluck('id')
|
||||||
->all();
|
->all();
|
||||||
$user->roles()->sync($roleIds);
|
$user->roles()->sync($roleIds);
|
||||||
|
|
||||||
|
if (in_array('ROLE_FOUNDER', $roleNames, true) && $user->email_verified_at === null) {
|
||||||
|
$user->forceFill(['email_verified_at' => now()])->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->loadMissing('rank');
|
$user->loadMissing('rank');
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button, Card, Container, Form } from 'react-bootstrap'
|
import { Button, Card, Container, Form } from 'react-bootstrap'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -53,10 +53,18 @@ export default function Login() {
|
|||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-2 text-end">
|
||||||
|
<Link to="/reset-password">{t('auth.forgot_password')}</Link>
|
||||||
|
</div>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Button type="submit" variant="dark" disabled={loading}>
|
<div className="d-flex w-100 align-items-center gap-2">
|
||||||
{loading ? t('form.signing_in') : t('form.sign_in')}
|
<Button as={Link} to="/" type="button" variant="outline-secondary" disabled={loading}>
|
||||||
</Button>
|
{t('acp.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
|
||||||
|
{loading ? t('form.signing_in') : t('form.sign_in')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
113
resources/js/pages/ResetPassword.jsx
Normal file
113
resources/js/pages/ResetPassword.jsx
Normal 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('/')
|
||||||
|
}
|
||||||
|
} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -81,6 +81,18 @@
|
|||||||
"auth.login_title": "Anmelden",
|
"auth.login_title": "Anmelden",
|
||||||
"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_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",
|
||||||
@@ -174,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",
|
||||||
|
|||||||
@@ -81,6 +81,18 @@
|
|||||||
"auth.login_title": "Log in",
|
"auth.login_title": "Log in",
|
||||||
"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_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",
|
||||||
@@ -164,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",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
18
tests/Feature/PingControllerTest.php
Normal file
18
tests/Feature/PingControllerTest.php
Normal 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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -285,3 +285,28 @@ it('updates user name and email as admin', function (): void {
|
|||||||
expect($target->email)->toBe('new@example.com');
|
expect($target->email)->toBe('new@example.com');
|
||||||
expect($target->email_verified_at)->toBeNull();
|
expect($target->email_verified_at)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks email verified when assigning founder role', function (): void {
|
||||||
|
$admin = makeAdmin();
|
||||||
|
$founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER'], ['color' => '#111111']);
|
||||||
|
$target = User::factory()->create([
|
||||||
|
'name' => 'Target',
|
||||||
|
'email' => 'target@example.com',
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin->roles()->syncWithoutDetaching([$founderRole->id]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($admin);
|
||||||
|
$response = $this->patchJson("/api/users/{$target->id}", [
|
||||||
|
'name' => 'Target',
|
||||||
|
'email' => 'target@example.com',
|
||||||
|
'rank_id' => null,
|
||||||
|
'roles' => ['ROLE_FOUNDER'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$target->refresh();
|
||||||
|
expect($target->email_verified_at)->not()->toBeNull();
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ it('version set fails when invalid version', function (): void {
|
|||||||
expect($exitCode)->toBe(1);
|
expect($exitCode)->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('version fetch fails when no version', function (): void {
|
it('version fetch succeeds without db version when composer version exists', function (): void {
|
||||||
Setting::where('key', 'version')->delete();
|
Setting::where('key', 'version')->delete();
|
||||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:fetch');
|
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:fetch');
|
||||||
expect($exitCode)->toBe(1);
|
expect($exitCode)->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('version release fails when missing config', function (): void {
|
it('version release fails when missing config', function (): void {
|
||||||
|
|||||||
@@ -53,13 +53,16 @@ namespace {
|
|||||||
|
|
||||||
it('fetches build count and syncs composer metadata', function (): void {
|
it('fetches build count and syncs composer metadata', function (): void {
|
||||||
withComposerBackupForFetch(function (): void {
|
withComposerBackupForFetch(function (): void {
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
Setting::updateOrCreate(['key' => 'version'], ['value' => '0.0.0']);
|
||||||
|
$composer = json_decode((string) file_get_contents(base_path('composer.json')), true);
|
||||||
|
$expectedVersion = (string) ($composer['version'] ?? '');
|
||||||
|
|
||||||
$exitCode = Artisan::call('version:fetch');
|
$exitCode = Artisan::call('version:fetch');
|
||||||
expect($exitCode)->toBe(0);
|
expect($exitCode)->toBe(0);
|
||||||
|
|
||||||
$build = Setting::where('key', 'build')->value('value');
|
$build = Setting::where('key', 'build')->value('value');
|
||||||
expect(is_numeric($build))->toBeTrue();
|
expect(is_numeric($build))->toBeTrue();
|
||||||
|
expect(Setting::where('key', 'version')->value('value'))->toBe($expectedVersion);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
12
tests/run-shell-tests.sh
Normal file
12
tests/run-shell-tests.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if ! command -v bats >/dev/null 2>&1; then
|
||||||
|
echo "bats is not installed. Install with: brew install bats-core" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bats tests/shell/git_update.bats
|
||||||
Reference in New Issue
Block a user