админка
This commit is contained in:
+18
-3
@@ -61,9 +61,10 @@ MAIL_FROM_NAME="${APP_NAME}"
|
|||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=ru-central1
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=warcraft-client
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_ENDPOINT=https://storage.yandexcloud.net
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
|
||||||
WWWUSER=1000
|
WWWUSER=1000
|
||||||
WWWGROUP=1000
|
WWWGROUP=1000
|
||||||
@@ -79,11 +80,22 @@ AZEROTHCORE_AUTH_DB_PASSWORD=password
|
|||||||
AZEROTHCORE_AUTH_DB_SOCKET=
|
AZEROTHCORE_AUTH_DB_SOCKET=
|
||||||
AZEROTHCORE_AUTH_DB_CHARSET=utf8mb4
|
AZEROTHCORE_AUTH_DB_CHARSET=utf8mb4
|
||||||
AZEROTHCORE_AUTH_DB_COLLATION=utf8mb4_unicode_ci
|
AZEROTHCORE_AUTH_DB_COLLATION=utf8mb4_unicode_ci
|
||||||
|
AZEROTHCORE_CHARACTERS_CONNECTION=azerothcore_characters
|
||||||
|
AZEROTHCORE_CHARACTERS_DB_HOST=host.docker.internal
|
||||||
|
AZEROTHCORE_CHARACTERS_DB_PORT=3306
|
||||||
|
AZEROTHCORE_CHARACTERS_DB_DATABASE=acore_characters
|
||||||
|
AZEROTHCORE_CHARACTERS_DB_USERNAME=root
|
||||||
|
AZEROTHCORE_CHARACTERS_DB_PASSWORD=password
|
||||||
|
AZEROTHCORE_CHARACTERS_DB_SOCKET=
|
||||||
|
AZEROTHCORE_CHARACTERS_DB_CHARSET=utf8mb4
|
||||||
|
AZEROTHCORE_CHARACTERS_DB_COLLATION=utf8mb4_unicode_ci
|
||||||
AZEROTHCORE_ACCOUNT_GMLEVEL=0
|
AZEROTHCORE_ACCOUNT_GMLEVEL=0
|
||||||
AZEROTHCORE_ACCOUNT_REALM_ID=-1
|
AZEROTHCORE_ACCOUNT_REALM_ID=-1
|
||||||
AZEROTHCORE_ACCOUNT_EXPANSION=2
|
AZEROTHCORE_ACCOUNT_EXPANSION=2
|
||||||
AZEROTHCORE_ACCOUNT_ACCESS_COMMENT="registered via moonwell-web"
|
AZEROTHCORE_ACCOUNT_ACCESS_COMMENT="registered via moonwell-web"
|
||||||
AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false
|
AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false
|
||||||
|
MOONWELL_ADMIN_MIN_GMLEVEL=3
|
||||||
|
MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin"
|
||||||
|
|
||||||
MOONWELL_SERVER_NAME=Moonwell
|
MOONWELL_SERVER_NAME=Moonwell
|
||||||
MOONWELL_REALM_NAME=Moonwell
|
MOONWELL_REALM_NAME=Moonwell
|
||||||
@@ -94,5 +106,8 @@ MOONWELL_RATE_EXPERIENCE=x3
|
|||||||
MOONWELL_RATE_GOLD=x2
|
MOONWELL_RATE_GOLD=x2
|
||||||
MOONWELL_RATE_PROFESSION=x2
|
MOONWELL_RATE_PROFESSION=x2
|
||||||
MOONWELL_RATE_REPUTATION=x1
|
MOONWELL_RATE_REPUTATION=x1
|
||||||
|
MOONWELL_CLIENT_DISK=s3
|
||||||
|
MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip"
|
||||||
|
MOONWELL_CLIENT_URL_TTL=30
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ Laravel 12-приложение с WoW-лендингом и публичной
|
|||||||
- Главная страница оформлена в тёмной cinematic-стилистике с компоновкой, вдохновлённой официальным сайтом WoW.
|
- Главная страница оформлена в тёмной cinematic-стилистике с компоновкой, вдохновлённой официальным сайтом WoW.
|
||||||
- Форма регистрации создаёт аккаунт напрямую в `acore_auth` через отдельное соединение `azerothcore_auth`.
|
- Форма регистрации создаёт аккаунт напрямую в `acore_auth` через отдельное соединение `azerothcore_auth`.
|
||||||
- При регистрации заполняются таблицы `account`, `realmcharacters` и `account_access`.
|
- При регистрации заполняются таблицы `account`, `realmcharacters` и `account_access`.
|
||||||
|
- Регистрация возможна только по инвайт-коду, который хранится в базе сайта.
|
||||||
|
- Добавлены личный кабинет и админка поверх игровых аккаунтов из auth-базы.
|
||||||
|
- В кабинете доступны смена пароля, список персонажей и ссылка на клиент игры из S3.
|
||||||
|
- В админке доступны просмотр всех аккаунтов, их персонажей, смена `gmlevel` и выпуск инвайт-кодов.
|
||||||
- Проект подготовлен под Laravel Sail: приложение работает в контейнере, а AzerothCore auth DB берётся с хоста через `host.docker.internal:3306`.
|
- Проект подготовлен под Laravel Sail: приложение работает в контейнере, а AzerothCore auth DB берётся с хоста через `host.docker.internal:3306`.
|
||||||
|
|
||||||
## Как запускать через Sail
|
## Как запускать через Sail
|
||||||
@@ -89,12 +93,20 @@ chmod +x scripts/deploy-ubuntu.sh
|
|||||||
- `AZEROTHCORE_AUTH_DB_DATABASE=acore_auth`
|
- `AZEROTHCORE_AUTH_DB_DATABASE=acore_auth`
|
||||||
- `AZEROTHCORE_AUTH_DB_USERNAME=root`
|
- `AZEROTHCORE_AUTH_DB_USERNAME=root`
|
||||||
- `AZEROTHCORE_AUTH_DB_PASSWORD=password`
|
- `AZEROTHCORE_AUTH_DB_PASSWORD=password`
|
||||||
|
- `AZEROTHCORE_CHARACTERS_DB_DATABASE=acore_characters`
|
||||||
|
- `MOONWELL_ADMIN_MIN_GMLEVEL=3`
|
||||||
|
- `MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip"`
|
||||||
|
- `AWS_ENDPOINT=https://storage.yandexcloud.net`
|
||||||
|
- `AWS_BUCKET=warcraft-client`
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
- `compose.yaml` — Sail-окружение с `laravel.test` и локальным MySQL для самого сайта.
|
- `compose.yaml` — Sail-окружение с `laravel.test` и локальным MySQL для самого сайта.
|
||||||
- `config/database.php` — отдельное соединение `azerothcore_auth`.
|
- `config/database.php` — отдельное соединение `azerothcore_auth`.
|
||||||
- `config/moonwell.php` — настройки realm/landing и регистрации.
|
- `config/moonwell.php` — настройки realm/landing и регистрации.
|
||||||
|
- `app/Auth/AzerothCoreUserProvider.php` — Laravel-auth поверх игровых аккаунтов.
|
||||||
|
- `app/Services/AzerothCoreAccountService.php` — работа с аккаунтами, персонажами, паролями и `gmlevel`.
|
||||||
|
- `app/Services/GameClientDownloadService.php` — генерация ссылки на клиент из S3.
|
||||||
- `app/Services/AzerothCoreAccountRegistrar.php` — SRP6-совместимое создание учётной записи AzerothCore.
|
- `app/Services/AzerothCoreAccountRegistrar.php` — SRP6-совместимое создание учётной записи AzerothCore.
|
||||||
- `app/Http/Controllers/LandingController.php` — данные лендинга и обработка формы.
|
- `app/Http/Controllers/LandingController.php` — данные лендинга и обработка формы.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Models\GameAccountUser;
|
||||||
|
use App\Services\AzerothCoreAccountService;
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
use Illuminate\Contracts\Auth\UserProvider;
|
||||||
|
|
||||||
|
class AzerothCoreUserProvider implements UserProvider
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AzerothCoreAccountService $accounts)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retrieveById($identifier): ?Authenticatable
|
||||||
|
{
|
||||||
|
if (! is_numeric($identifier)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->accounts->findUserById((int) $identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retrieveByToken($identifier, #[\SensitiveParameter] $token): ?Authenticatable
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateRememberToken(Authenticatable $user, #[\SensitiveParameter] $token): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retrieveByCredentials(#[\SensitiveParameter] array $credentials): ?Authenticatable
|
||||||
|
{
|
||||||
|
$username = $credentials['username'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($username) || trim($username) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->accounts->findUserByUsername($username);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateCredentials(Authenticatable $user, #[\SensitiveParameter] array $credentials): bool
|
||||||
|
{
|
||||||
|
return $user instanceof GameAccountUser
|
||||||
|
&& isset($credentials['password'])
|
||||||
|
&& is_string($credentials['password'])
|
||||||
|
&& $this->accounts->validatePassword($user, $credentials['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class InviteCodeException extends RuntimeException
|
||||||
|
{
|
||||||
|
public string $field = 'invite_code';
|
||||||
|
|
||||||
|
public static function invalid(): self
|
||||||
|
{
|
||||||
|
return new self('Укажи корректный инвайт-код.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function alreadyUsed(): self
|
||||||
|
{
|
||||||
|
return new self('Этот инвайт-код уже использован.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\UpdateAccountAccessRequest;
|
||||||
|
use App\Services\AzerothCoreAccountService;
|
||||||
|
use App\Services\InviteCodeService;
|
||||||
|
use App\Support\WowData;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class AccountController extends Controller
|
||||||
|
{
|
||||||
|
public function index(
|
||||||
|
Request $request,
|
||||||
|
AzerothCoreAccountService $accounts,
|
||||||
|
InviteCodeService $inviteCodes,
|
||||||
|
): View
|
||||||
|
{
|
||||||
|
$search = trim((string) $request->query('search', ''));
|
||||||
|
|
||||||
|
return view('admin.accounts.index', [
|
||||||
|
'accounts' => $accounts->listAccounts($search !== '' ? $search : null),
|
||||||
|
'inviteCodes' => $inviteCodes->latest(),
|
||||||
|
'inviteStats' => $inviteCodes->stats(),
|
||||||
|
'search' => $search,
|
||||||
|
'accessLevels' => WowData::securityLevels(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAccess(int $accountId, UpdateAccountAccessRequest $request, AzerothCoreAccountService $accounts): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($accounts->findUserById($accountId), 404);
|
||||||
|
|
||||||
|
$accounts->updateAccessLevel($accountId, (int) $request->input('gmlevel'));
|
||||||
|
|
||||||
|
return back()->with('status', 'Уровень доступа обновлён.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\CreateInviteCodesRequest;
|
||||||
|
use App\Services\InviteCodeService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class InviteCodeController extends Controller
|
||||||
|
{
|
||||||
|
public function store(CreateInviteCodesRequest $request, InviteCodeService $inviteCodes): RedirectResponse
|
||||||
|
{
|
||||||
|
$created = $inviteCodes->createCodes(
|
||||||
|
(int) $request->integer('quantity'),
|
||||||
|
$request->user(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('status', sprintf('Создано инвайт-кодов: %d.', $created->count()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\LoginRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class LoginController extends Controller
|
||||||
|
{
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(LoginRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if (! Auth::attempt($request->safe()->only(['username', 'password']))) {
|
||||||
|
return back()
|
||||||
|
->withErrors(['username' => 'Неверный логин или пароль.'])
|
||||||
|
->onlyInput('username');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
return redirect()->intended(route('cabinet.index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('login')
|
||||||
|
->with('status', 'Сеанс завершён. Можно войти снова.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\ChangePasswordRequest;
|
||||||
|
use App\Services\AzerothCoreAccountService;
|
||||||
|
use App\Services\GameClientDownloadService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class CabinetController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, AzerothCoreAccountService $accounts): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return view('cabinet.index', [
|
||||||
|
'user' => $user,
|
||||||
|
'characters' => $accounts->charactersForAccount($user->id),
|
||||||
|
'clientFilename' => config('moonwell.client.object_key'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePassword(ChangePasswordRequest $request, AzerothCoreAccountService $accounts): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $accounts->validatePassword($user, $request->string('current_password')->toString())) {
|
||||||
|
return back()->withErrors(['current_password' => 'Текущий пароль указан неверно.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$accounts->updatePassword($user, $request->string('password')->toString());
|
||||||
|
|
||||||
|
return back()->with('status', 'Пароль успешно обновлён.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function client(GameClientDownloadService $clientDownloadService): RedirectResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return redirect()->away($clientDownloadService->temporaryUrl());
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
|
||||||
|
return back()->with('error', 'Не удалось получить ссылку на клиент. Попробуй ещё раз позже.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Exceptions\DuplicateGameAccountException;
|
use App\Exceptions\DuplicateGameAccountException;
|
||||||
|
use App\Exceptions\InviteCodeException;
|
||||||
use App\Http\Requests\RegisterGameAccountRequest;
|
use App\Http\Requests\RegisterGameAccountRequest;
|
||||||
use App\Services\AzerothCoreAccountRegistrar;
|
use App\Services\AzerothCoreAccountRegistrar;
|
||||||
|
use App\Services\InviteCodeService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@@ -85,24 +87,36 @@ class LandingController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register(RegisterGameAccountRequest $request, AzerothCoreAccountRegistrar $registrar): RedirectResponse
|
public function register(
|
||||||
|
RegisterGameAccountRequest $request,
|
||||||
|
AzerothCoreAccountRegistrar $registrar,
|
||||||
|
InviteCodeService $inviteCodes,
|
||||||
|
): RedirectResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$result = $registrar->register(
|
$result = $inviteCodes->redeemForRegistration(
|
||||||
|
$request->string('invite_code')->toString(),
|
||||||
$request->string('username')->toString(),
|
$request->string('username')->toString(),
|
||||||
$request->string('email')->toString(),
|
fn (): array => $registrar->register(
|
||||||
$request->string('password')->toString(),
|
$request->string('username')->toString(),
|
||||||
|
$request->string('email')->toString(),
|
||||||
|
$request->string('password')->toString(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (DuplicateGameAccountException $exception) {
|
} catch (DuplicateGameAccountException $exception) {
|
||||||
return back()
|
return back()
|
||||||
->withErrors([$exception->field => $exception->getMessage()])
|
->withErrors([$exception->field => $exception->getMessage()])
|
||||||
->withInput($request->safe()->only(['username', 'email']));
|
->withInput($request->safe()->only(['username', 'email', 'invite_code']));
|
||||||
|
} catch (InviteCodeException $exception) {
|
||||||
|
return back()
|
||||||
|
->withErrors([$exception->field => $exception->getMessage()])
|
||||||
|
->withInput($request->safe()->only(['username', 'email', 'invite_code']));
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
report($exception);
|
report($exception);
|
||||||
|
|
||||||
return back()
|
return back()
|
||||||
->with('registration_error', 'Не удалось создать аккаунт. Проверь настройки сервера и попробуй снова.')
|
->with('registration_error', 'Не удалось создать аккаунт. Проверь настройки сервера и попробуй снова.')
|
||||||
->withInput($request->safe()->only(['username', 'email']));
|
->withInput($request->safe()->only(['username', 'email', 'invite_code']));
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureGameMaster
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
abort_unless(
|
||||||
|
$user && $user->canAccessAdminPanel(),
|
||||||
|
Response::HTTP_FORBIDDEN,
|
||||||
|
'Недостаточно прав для доступа к админке.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ChangePasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'current_password' => ['required', 'string', 'max:32'],
|
||||||
|
'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'current_password.required' => 'Укажи текущий пароль.',
|
||||||
|
'current_password.max' => 'Текущий пароль не должен быть длиннее 32 символов.',
|
||||||
|
'password.required' => 'Укажи новый пароль.',
|
||||||
|
'password.min' => 'Новый пароль должен содержать минимум 8 символов.',
|
||||||
|
'password.max' => 'Новый пароль не должен быть длиннее 32 символов.',
|
||||||
|
'password.confirmed' => 'Подтверждение нового пароля не совпадает.',
|
||||||
|
'password.regex' => 'Новый пароль должен содержать хотя бы одну букву и одну цифру.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class CreateInviteCodesRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quantity' => ['required', 'integer', 'min:1', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quantity.required' => 'Укажи количество инвайт-кодов.',
|
||||||
|
'quantity.integer' => 'Количество должно быть числом.',
|
||||||
|
'quantity.min' => 'Нужно создать минимум один инвайт-код.',
|
||||||
|
'quantity.max' => 'За один раз можно создать не больше 50 инвайт-кодов.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'],
|
||||||
|
'password' => ['required', 'string', 'min:3', 'max:32'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'username.required' => 'Укажи логин аккаунта.',
|
||||||
|
'username.min' => 'Логин должен содержать минимум 3 символа.',
|
||||||
|
'username.max' => 'Логин не должен быть длиннее 32 символов.',
|
||||||
|
'username.regex' => 'Логин может содержать только латинские буквы и цифры.',
|
||||||
|
'password.required' => 'Укажи пароль.',
|
||||||
|
'password.min' => 'Пароль должен содержать минимум 3 символа.',
|
||||||
|
'password.max' => 'Пароль не должен быть длиннее 32 символов.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ class RegisterGameAccountRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'],
|
'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'],
|
||||||
'email' => ['required', 'string', 'email:rfc', 'max:255'],
|
'email' => ['required', 'string', 'email:rfc', 'max:255'],
|
||||||
|
'invite_code' => ['required', 'string', 'min:6', 'max:32'],
|
||||||
'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'],
|
'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'],
|
||||||
'terms' => ['accepted'],
|
'terms' => ['accepted'],
|
||||||
];
|
];
|
||||||
@@ -39,6 +40,10 @@ class RegisterGameAccountRequest extends FormRequest
|
|||||||
'email.string' => 'Email должен быть строкой.',
|
'email.string' => 'Email должен быть строкой.',
|
||||||
'email.email' => 'Укажи корректный email адрес.',
|
'email.email' => 'Укажи корректный email адрес.',
|
||||||
'email.max' => 'Email не должен быть длиннее 255 символов.',
|
'email.max' => 'Email не должен быть длиннее 255 символов.',
|
||||||
|
'invite_code.required' => 'Укажи инвайт-код.',
|
||||||
|
'invite_code.string' => 'Инвайт-код должен быть строкой.',
|
||||||
|
'invite_code.min' => 'Инвайт-код выглядит слишком коротким.',
|
||||||
|
'invite_code.max' => 'Инвайт-код выглядит слишком длинным.',
|
||||||
'password.required' => 'Укажи пароль.',
|
'password.required' => 'Укажи пароль.',
|
||||||
'password.string' => 'Пароль должен быть строкой.',
|
'password.string' => 'Пароль должен быть строкой.',
|
||||||
'password.min' => 'Пароль должен содержать минимум 8 символов.',
|
'password.min' => 'Пароль должен содержать минимум 8 символов.',
|
||||||
@@ -57,6 +62,7 @@ class RegisterGameAccountRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'username' => 'логин аккаунта',
|
'username' => 'логин аккаунта',
|
||||||
'email' => 'email',
|
'email' => 'email',
|
||||||
|
'invite_code' => 'инвайт-код',
|
||||||
'password' => 'пароль',
|
'password' => 'пароль',
|
||||||
'terms' => 'правила',
|
'terms' => 'правила',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateAccountAccessRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'gmlevel' => ['required', 'integer', Rule::in(array_keys(\App\Support\WowData::securityLevels()))],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'gmlevel.required' => 'Укажи уровень доступа.',
|
||||||
|
'gmlevel.integer' => 'Уровень доступа должен быть числом.',
|
||||||
|
'gmlevel.in' => 'Выбран недопустимый уровень доступа.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\WowData;
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class GameAccountUser implements AuthenticatableContract
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $id,
|
||||||
|
public readonly string $username,
|
||||||
|
public readonly string $email,
|
||||||
|
public readonly string $regMail,
|
||||||
|
public readonly int $gmLevel,
|
||||||
|
public readonly bool $locked,
|
||||||
|
public readonly ?string $lastLoginAt,
|
||||||
|
public readonly ?string $joinedAt,
|
||||||
|
public readonly string $saltHex,
|
||||||
|
public readonly string $verifierHex,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuthIdentifierName(): string
|
||||||
|
{
|
||||||
|
return 'id';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuthIdentifier(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuthPasswordName(): string
|
||||||
|
{
|
||||||
|
return 'verifier';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuthPassword(): string
|
||||||
|
{
|
||||||
|
return $this->verifierHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRememberToken(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRememberToken($value): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRememberTokenName(): string
|
||||||
|
{
|
||||||
|
return 'remember_token';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accessLabel(): string
|
||||||
|
{
|
||||||
|
return WowData::securityLevelName($this->gmLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAccessAdminPanel(): bool
|
||||||
|
{
|
||||||
|
return $this->gmLevel >= config('moonwell.portal.admin_min_gm_level');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function joinedAtLabel(): string
|
||||||
|
{
|
||||||
|
return $this->formatDate($this->joinedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastLoginLabel(): string
|
||||||
|
{
|
||||||
|
return $this->formatDate($this->lastLoginAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDate(?string $value): string
|
||||||
|
{
|
||||||
|
return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class InviteCode extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'created_by_account_id',
|
||||||
|
'created_by_username',
|
||||||
|
'redeemed_by_account_id',
|
||||||
|
'redeemed_by_username',
|
||||||
|
'redeemed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'redeemed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Auth\AzerothCoreUserProvider;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -19,6 +21,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
Auth::provider('azerothcore', function ($app): AzerothCoreUserProvider {
|
||||||
|
return $app->make(AzerothCoreUserProvider::class);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,25 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Exceptions\DuplicateGameAccountException;
|
use App\Exceptions\DuplicateGameAccountException;
|
||||||
use Brick\Math\BigInteger;
|
|
||||||
use Illuminate\Database\ConnectionInterface;
|
use Illuminate\Database\ConnectionInterface;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class AzerothCoreAccountRegistrar
|
class AzerothCoreAccountRegistrar
|
||||||
{
|
{
|
||||||
private const string MODULUS_HEX = '894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7';
|
public function __construct(private readonly AzerothCoreSrpService $srp)
|
||||||
|
{
|
||||||
private const int GENERATOR = 7;
|
}
|
||||||
|
|
||||||
private const int BYTE_LENGTH = 32;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{id: int, username: string}
|
* @return array{id: int, username: string}
|
||||||
*/
|
*/
|
||||||
public function register(string $username, string $email, string $password): array
|
public function register(string $username, string $email, string $password): array
|
||||||
{
|
{
|
||||||
$normalizedUsername = $this->normalizeUpperOnlyLatin(trim($username));
|
$normalizedUsername = $this->srp->normalizeUsername($username);
|
||||||
$normalizedPassword = $this->normalizeUpperOnlyLatin($password);
|
$normalizedPassword = $this->srp->normalizePassword($password);
|
||||||
$normalizedEmail = $this->normalizeUpperOnlyLatin(trim($email));
|
$normalizedEmail = $this->srp->normalizeEmail($email);
|
||||||
[$saltHex, $verifierHex] = $this->makeRegistrationData($normalizedUsername, $normalizedPassword);
|
[$saltHex, $verifierHex] = $this->srp->makeRegistrationData($normalizedUsername, $normalizedPassword);
|
||||||
|
|
||||||
$connection = DB::connection(config('moonwell.auth_connection'));
|
$connection = DB::connection(config('moonwell.auth_connection'));
|
||||||
|
|
||||||
@@ -97,30 +94,6 @@ class AzerothCoreAccountRegistrar
|
|||||||
return $connection->table('account')->where($column, $value)->exists();
|
return $connection->table('account')->where($column, $value)->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: string, 1: string}
|
|
||||||
*/
|
|
||||||
private function makeRegistrationData(string $username, string $password): array
|
|
||||||
{
|
|
||||||
$salt = random_bytes(self::BYTE_LENGTH);
|
|
||||||
$innerDigest = hash('sha1', sprintf('%s:%s', $username, $password), true);
|
|
||||||
$xDigest = hash('sha1', $salt.$innerDigest, true);
|
|
||||||
$modulus = BigInteger::fromBase(self::MODULUS_HEX, 16);
|
|
||||||
$exponent = BigInteger::fromBytes(strrev($xDigest), false);
|
|
||||||
$verifier = BigInteger::of(self::GENERATOR)->modPow($exponent, $modulus);
|
|
||||||
$verifierBytes = strrev(str_pad($verifier->toBytes(false), self::BYTE_LENGTH, "\0", STR_PAD_LEFT));
|
|
||||||
|
|
||||||
return [
|
|
||||||
strtoupper(bin2hex($salt)),
|
|
||||||
strtoupper(bin2hex($verifierBytes)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeUpperOnlyLatin(string $value): string
|
|
||||||
{
|
|
||||||
return strtoupper($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isDuplicateKeyException(QueryException $exception): bool
|
private function isDuplicateKeyException(QueryException $exception): bool
|
||||||
{
|
{
|
||||||
return (string) $exception->getCode() === '23000';
|
return (string) $exception->getCode() === '23000';
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\GameAccountUser;
|
||||||
|
use App\Support\WowData;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class AzerothCoreAccountService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AzerothCoreSrpService $srp)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findUserById(int $accountId): ?GameAccountUser
|
||||||
|
{
|
||||||
|
$record = $this->authConnection()
|
||||||
|
->table('account')
|
||||||
|
->selectRaw('id, username, email, reg_mail, locked, last_login, joindate, HEX(salt) as salt_hex, HEX(verifier) as verifier_hex')
|
||||||
|
->where('id', $accountId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gmLevel = $this->accessLevelsFor([$accountId])[$accountId] ?? 0;
|
||||||
|
|
||||||
|
return $this->mapUser($record, $gmLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findUserByUsername(string $username): ?GameAccountUser
|
||||||
|
{
|
||||||
|
$record = $this->authConnection()
|
||||||
|
->table('account')
|
||||||
|
->selectRaw('id, username, email, reg_mail, locked, last_login, joindate, HEX(salt) as salt_hex, HEX(verifier) as verifier_hex')
|
||||||
|
->where('username', $this->srp->normalizeUsername($username))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gmLevel = $this->accessLevelsFor([(int) $record->id])[(int) $record->id] ?? 0;
|
||||||
|
|
||||||
|
return $this->mapUser($record, $gmLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validatePassword(GameAccountUser $user, string $password): bool
|
||||||
|
{
|
||||||
|
return $this->srp->credentialsMatch($user->username, $password, $user->saltHex, $user->verifierHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePassword(GameAccountUser $user, string $newPassword): void
|
||||||
|
{
|
||||||
|
[$saltHex, $verifierHex] = $this->srp->makeRegistrationData($user->username, $newPassword);
|
||||||
|
|
||||||
|
$this->authConnection()->update(
|
||||||
|
'UPDATE account SET salt = UNHEX(?), verifier = UNHEX(?), session_key = NULL, failed_logins = 0 WHERE id = ?',
|
||||||
|
[$saltHex, $verifierHex, $user->id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function listAccounts(?string $search = null): Collection
|
||||||
|
{
|
||||||
|
$query = $this->authConnection()
|
||||||
|
->table('account')
|
||||||
|
->select('id', 'username', 'email', 'reg_mail', 'locked', 'last_login', 'joindate', 'online')
|
||||||
|
->orderByDesc('id');
|
||||||
|
|
||||||
|
if ($search !== null && trim($search) !== '') {
|
||||||
|
$normalizedSearch = $this->srp->normalizeUsername($search);
|
||||||
|
|
||||||
|
$query->where(function ($builder) use ($normalizedSearch): void {
|
||||||
|
$builder
|
||||||
|
->where('username', 'like', "%{$normalizedSearch}%")
|
||||||
|
->orWhere('email', 'like', "%{$normalizedSearch}%")
|
||||||
|
->orWhere('reg_mail', 'like', "%{$normalizedSearch}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$accounts = $query->get();
|
||||||
|
$accountIds = $accounts->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||||
|
$accessLevels = $this->accessLevelsFor($accountIds);
|
||||||
|
$characters = $this->charactersForAccountIds($accountIds);
|
||||||
|
|
||||||
|
return $accounts->map(function (object $account) use ($accessLevels, $characters): array {
|
||||||
|
$accountId = (int) $account->id;
|
||||||
|
$gmLevel = $accessLevels[$accountId] ?? 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $accountId,
|
||||||
|
'username' => $account->username,
|
||||||
|
'email' => $account->email,
|
||||||
|
'reg_mail' => $account->reg_mail,
|
||||||
|
'gm_level' => $gmLevel,
|
||||||
|
'access_label' => WowData::securityLevelName($gmLevel),
|
||||||
|
'locked' => (bool) $account->locked,
|
||||||
|
'online' => (bool) $account->online,
|
||||||
|
'joined_at' => $this->formatDate($account->joindate),
|
||||||
|
'last_login_at' => $this->formatDate($account->last_login),
|
||||||
|
'characters' => $characters->get($accountId, collect()),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function charactersForAccount(int $accountId): Collection
|
||||||
|
{
|
||||||
|
return $this->charactersForAccountIds([$accountId])->get($accountId, collect());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAccessLevel(int $accountId, int $gmLevel): void
|
||||||
|
{
|
||||||
|
$realmId = $this->targetRealmId();
|
||||||
|
|
||||||
|
$this->authConnection()->statement(
|
||||||
|
'INSERT INTO account_access (id, gmlevel, RealmID, comment) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE gmlevel = VALUES(gmlevel), comment = VALUES(comment)',
|
||||||
|
[
|
||||||
|
$accountId,
|
||||||
|
$gmLevel,
|
||||||
|
$realmId,
|
||||||
|
config('moonwell.portal.access_comment'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $accountIds
|
||||||
|
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, array<string, mixed>>>
|
||||||
|
*/
|
||||||
|
private function charactersForAccountIds(array $accountIds): Collection
|
||||||
|
{
|
||||||
|
if ($accountIds === []) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->charactersConnection()
|
||||||
|
->table('characters')
|
||||||
|
->select('guid', 'account', 'name', 'race', 'class', 'gender', 'level', 'online', 'money', 'totaltime')
|
||||||
|
->whereIn('account', $accountIds)
|
||||||
|
->orderBy('account')
|
||||||
|
->orderByDesc('level')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(function (object $character): array {
|
||||||
|
return [
|
||||||
|
'guid' => (int) $character->guid,
|
||||||
|
'account' => (int) $character->account,
|
||||||
|
'name' => $character->name,
|
||||||
|
'level' => (int) $character->level,
|
||||||
|
'race' => WowData::raceName((int) $character->race),
|
||||||
|
'class' => WowData::className((int) $character->class),
|
||||||
|
'gender' => WowData::genderName((int) $character->gender),
|
||||||
|
'online' => (bool) $character->online,
|
||||||
|
'money' => WowData::formatMoney((int) $character->money),
|
||||||
|
'played' => WowData::formatPlayedTime((int) $character->totaltime),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->groupBy('account')
|
||||||
|
->map(fn (Collection $group): Collection => $group->values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $accountIds
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function accessLevelsFor(array $accountIds): array
|
||||||
|
{
|
||||||
|
if ($accountIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetRealmId = $this->targetRealmId();
|
||||||
|
$realmIds = array_values(array_unique([$targetRealmId, -1]));
|
||||||
|
|
||||||
|
return $this->authConnection()
|
||||||
|
->table('account_access')
|
||||||
|
->select('id', 'gmlevel', 'RealmID')
|
||||||
|
->whereIn('id', $accountIds)
|
||||||
|
->whereIn('RealmID', $realmIds)
|
||||||
|
->get()
|
||||||
|
->groupBy('id')
|
||||||
|
->map(function (Collection $rows) use ($targetRealmId): int {
|
||||||
|
$exactMatch = $rows->firstWhere('RealmID', $targetRealmId);
|
||||||
|
|
||||||
|
return (int) ($exactMatch->gmlevel ?? $rows->firstWhere('RealmID', -1)?->gmlevel ?? 0);
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapUser(object $record, int $gmLevel): GameAccountUser
|
||||||
|
{
|
||||||
|
return new GameAccountUser(
|
||||||
|
id: (int) $record->id,
|
||||||
|
username: $record->username,
|
||||||
|
email: $record->email,
|
||||||
|
regMail: $record->reg_mail,
|
||||||
|
gmLevel: $gmLevel,
|
||||||
|
locked: (bool) $record->locked,
|
||||||
|
lastLoginAt: $record->last_login,
|
||||||
|
joinedAt: $record->joindate,
|
||||||
|
saltHex: strtoupper($record->salt_hex),
|
||||||
|
verifierHex: strtoupper($record->verifier_hex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDate(?string $value): string
|
||||||
|
{
|
||||||
|
return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function targetRealmId(): int
|
||||||
|
{
|
||||||
|
return (int) config('moonwell.registration.realm_id', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authConnection()
|
||||||
|
{
|
||||||
|
return DB::connection(config('moonwell.auth_connection'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function charactersConnection()
|
||||||
|
{
|
||||||
|
return DB::connection(config('moonwell.characters_connection'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Brick\Math\BigInteger;
|
||||||
|
|
||||||
|
class AzerothCoreSrpService
|
||||||
|
{
|
||||||
|
private const string MODULUS_HEX = '894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7';
|
||||||
|
|
||||||
|
private const int GENERATOR = 7;
|
||||||
|
|
||||||
|
private const int BYTE_LENGTH = 32;
|
||||||
|
|
||||||
|
public function normalizeUsername(string $username): string
|
||||||
|
{
|
||||||
|
return strtoupper(trim($username));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeEmail(string $email): string
|
||||||
|
{
|
||||||
|
return strtoupper(trim($email));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizePassword(string $password): string
|
||||||
|
{
|
||||||
|
return strtoupper($password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: string, 1: string}
|
||||||
|
*/
|
||||||
|
public function makeRegistrationData(string $username, string $password): array
|
||||||
|
{
|
||||||
|
$saltHex = strtoupper(bin2hex(random_bytes(self::BYTE_LENGTH)));
|
||||||
|
|
||||||
|
return [$saltHex, $this->makeVerifierHex($username, $password, $saltHex)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function makeVerifierHex(string $username, string $password, string $saltHex): string
|
||||||
|
{
|
||||||
|
$normalizedUsername = $this->normalizeUsername($username);
|
||||||
|
$normalizedPassword = $this->normalizePassword($password);
|
||||||
|
$salt = hex2bin($saltHex);
|
||||||
|
|
||||||
|
if ($salt === false) {
|
||||||
|
throw new \InvalidArgumentException('Invalid salt hex provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$innerDigest = hash('sha1', sprintf('%s:%s', $normalizedUsername, $normalizedPassword), true);
|
||||||
|
$xDigest = hash('sha1', $salt.$innerDigest, true);
|
||||||
|
$modulus = BigInteger::fromBase(self::MODULUS_HEX, 16);
|
||||||
|
$exponent = BigInteger::fromBytes(strrev($xDigest), false);
|
||||||
|
$verifier = BigInteger::of(self::GENERATOR)->modPow($exponent, $modulus);
|
||||||
|
$verifierBytes = strrev(str_pad($verifier->toBytes(false), self::BYTE_LENGTH, "\0", STR_PAD_LEFT));
|
||||||
|
|
||||||
|
return strtoupper(bin2hex($verifierBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function credentialsMatch(string $username, string $password, string $saltHex, string $verifierHex): bool
|
||||||
|
{
|
||||||
|
return hash_equals(
|
||||||
|
strtoupper($verifierHex),
|
||||||
|
$this->makeVerifierHex($username, $password, $saltHex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class GameClientDownloadService
|
||||||
|
{
|
||||||
|
public function filename(): string
|
||||||
|
{
|
||||||
|
return basename($this->objectKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function temporaryUrl(): string
|
||||||
|
{
|
||||||
|
$disk = Storage::disk(config('moonwell.client.disk'));
|
||||||
|
$objectKey = $this->objectKey();
|
||||||
|
$filename = $this->filename();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $disk->temporaryUrl(
|
||||||
|
$objectKey,
|
||||||
|
now()->addMinutes(config('moonwell.client.temporary_url_minutes')),
|
||||||
|
['ResponseContentDisposition' => sprintf('attachment; filename="%s"', $filename)],
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return $disk->url($objectKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function objectKey(): string
|
||||||
|
{
|
||||||
|
return (string) config('moonwell.client.object_key');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\InviteCodeException;
|
||||||
|
use App\Models\GameAccountUser;
|
||||||
|
use App\Models\InviteCode;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class InviteCodeService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, \App\Models\InviteCode>
|
||||||
|
*/
|
||||||
|
public function createCodes(int $quantity, GameAccountUser $creator): Collection
|
||||||
|
{
|
||||||
|
$created = collect();
|
||||||
|
|
||||||
|
for ($index = 0; $index < $quantity; $index++) {
|
||||||
|
$created->push(InviteCode::query()->create([
|
||||||
|
'code' => $this->generateUniqueCode(),
|
||||||
|
'created_by_account_id' => $creator->id,
|
||||||
|
'created_by_username' => $creator->username,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template TReturn
|
||||||
|
*
|
||||||
|
* @param Closure(): TReturn $callback
|
||||||
|
* @return TReturn
|
||||||
|
*/
|
||||||
|
public function redeemForRegistration(string $code, string $username, Closure $callback): mixed
|
||||||
|
{
|
||||||
|
$normalizedCode = $this->normalizeCode($code);
|
||||||
|
$normalizedUsername = strtoupper(trim($username));
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($callback, $normalizedCode, $normalizedUsername) {
|
||||||
|
/** @var InviteCode|null $invite */
|
||||||
|
$invite = InviteCode::query()
|
||||||
|
->where('code', $normalizedCode)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $invite) {
|
||||||
|
throw InviteCodeException::invalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invite->redeemed_at !== null) {
|
||||||
|
throw InviteCodeException::alreadyUsed();
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $callback();
|
||||||
|
|
||||||
|
$invite->forceFill([
|
||||||
|
'redeemed_by_account_id' => $result['id'],
|
||||||
|
'redeemed_by_username' => $normalizedUsername,
|
||||||
|
'redeemed_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, \App\Models\InviteCode>
|
||||||
|
*/
|
||||||
|
public function latest(int $limit = 100): Collection
|
||||||
|
{
|
||||||
|
return InviteCode::query()
|
||||||
|
->latest('id')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: int, available: int, redeemed: int}
|
||||||
|
*/
|
||||||
|
public function stats(): array
|
||||||
|
{
|
||||||
|
$total = InviteCode::query()->count();
|
||||||
|
$redeemed = InviteCode::query()->whereNotNull('redeemed_at')->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'available' => $total - $redeemed,
|
||||||
|
'redeemed' => $redeemed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUniqueCode(): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$code = $this->normalizeCode(
|
||||||
|
Str::upper(Str::random(4)).'-'.Str::upper(Str::random(4)).'-'.Str::upper(Str::random(4)),
|
||||||
|
);
|
||||||
|
} while (InviteCode::query()->where('code', $code)->exists());
|
||||||
|
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeCode(string $code): string
|
||||||
|
{
|
||||||
|
return Str::upper(trim($code));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
class WowData
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function securityLevels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
0 => 'Игрок',
|
||||||
|
1 => 'Модератор',
|
||||||
|
2 => 'Гейммастер',
|
||||||
|
3 => 'Администратор',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function securityLevelName(int $level): string
|
||||||
|
{
|
||||||
|
return self::securityLevels()[$level] ?? 'Неизвестно';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function raceName(int $race): string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
1 => 'Человек',
|
||||||
|
2 => 'Орк',
|
||||||
|
3 => 'Дворф',
|
||||||
|
4 => 'Ночной эльф',
|
||||||
|
5 => 'Нежить',
|
||||||
|
6 => 'Таурен',
|
||||||
|
7 => 'Гном',
|
||||||
|
8 => 'Тролль',
|
||||||
|
10 => 'Эльф крови',
|
||||||
|
11 => 'Дреней',
|
||||||
|
][$race] ?? 'Неизвестно';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function className(int $class): string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
1 => 'Воин',
|
||||||
|
2 => 'Паладин',
|
||||||
|
3 => 'Охотник',
|
||||||
|
4 => 'Разбойник',
|
||||||
|
5 => 'Жрец',
|
||||||
|
6 => 'Рыцарь смерти',
|
||||||
|
7 => 'Шаман',
|
||||||
|
8 => 'Маг',
|
||||||
|
9 => 'Чернокнижник',
|
||||||
|
11 => 'Друид',
|
||||||
|
][$class] ?? 'Неизвестно';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function genderName(int $gender): string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
0 => 'Мужской',
|
||||||
|
1 => 'Женский',
|
||||||
|
][$gender] ?? 'Неизвестно';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function formatMoney(int $copper): string
|
||||||
|
{
|
||||||
|
$gold = intdiv($copper, 10000);
|
||||||
|
$silver = intdiv($copper % 10000, 100);
|
||||||
|
$bronze = $copper % 100;
|
||||||
|
|
||||||
|
return sprintf('%dз %dс %dм', $gold, $silver, $bronze);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function formatPlayedTime(int $seconds): string
|
||||||
|
{
|
||||||
|
$days = intdiv($seconds, 86400);
|
||||||
|
$hours = intdiv($seconds % 86400, 3600);
|
||||||
|
$minutes = intdiv($seconds % 3600, 60);
|
||||||
|
|
||||||
|
if ($days > 0) {
|
||||||
|
return sprintf('%dд %dч', $days, $hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hours > 0) {
|
||||||
|
return sprintf('%dч %dм', $hours, $minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%dм', $minutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -11,7 +11,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
$middleware->alias([
|
||||||
|
'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.29",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^2.10.1"
|
||||||
},
|
},
|
||||||
@@ -76,6 +77,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
"platform": {
|
||||||
|
"php": "8.3.30"
|
||||||
|
},
|
||||||
"optimize-autoloader": true,
|
"optimize-autoloader": true,
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
|
|||||||
Generated
+347
-2
@@ -4,8 +4,159 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "53b5d56b3b7e3cbac1713e68c8850f6c",
|
"content-hash": "e2e6a80574bb3ef4ce8387760252b91a",
|
||||||
"packages": [
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "aws/aws-crt-php",
|
||||||
|
"version": "v1.2.7",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||||
|
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||||
|
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=5.5"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||||
|
"yoast/phpunit-polyfills": "^1.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"classmap": [
|
||||||
|
"src/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "AWS SDK Common Runtime Team",
|
||||||
|
"email": "aws-sdk-common-runtime@amazon.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AWS Common Runtime for PHP",
|
||||||
|
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||||
|
"keywords": [
|
||||||
|
"amazon",
|
||||||
|
"aws",
|
||||||
|
"crt",
|
||||||
|
"sdk"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||||
|
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||||
|
},
|
||||||
|
"time": "2024-10-18T22:15:13+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aws/aws-sdk-php",
|
||||||
|
"version": "3.373.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||||
|
"reference": "483fba51c28b3a0c0647bf5100e0edca82090b18"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/483fba51c28b3a0c0647bf5100e0edca82090b18",
|
||||||
|
"reference": "483fba51c28b3a0c0647bf5100e0edca82090b18",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"aws/aws-crt-php": "^1.2.3",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-pcre": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"guzzlehttp/guzzle": "^7.4.5",
|
||||||
|
"guzzlehttp/promises": "^2.0",
|
||||||
|
"guzzlehttp/psr7": "^2.4.5",
|
||||||
|
"mtdowling/jmespath.php": "^2.8.0",
|
||||||
|
"php": ">=8.1",
|
||||||
|
"psr/http-message": "^1.0 || ^2.0",
|
||||||
|
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"andrewsville/php-token-reflection": "^1.4",
|
||||||
|
"aws/aws-php-sns-message-validator": "~1.0",
|
||||||
|
"behat/behat": "~3.0",
|
||||||
|
"composer/composer": "^2.7.8",
|
||||||
|
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
|
||||||
|
"doctrine/cache": "~1.4",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"ext-sockets": "*",
|
||||||
|
"phpunit/phpunit": "^10.0",
|
||||||
|
"psr/cache": "^2.0 || ^3.0",
|
||||||
|
"psr/simple-cache": "^2.0 || ^3.0",
|
||||||
|
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||||
|
"yoast/phpunit-polyfills": "^2.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||||
|
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||||
|
"ext-curl": "To send requests using cURL",
|
||||||
|
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||||
|
"ext-pcntl": "To use client-side monitoring",
|
||||||
|
"ext-sockets": "To use client-side monitoring"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Aws\\": "src/"
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"src/data/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Amazon Web Services",
|
||||||
|
"homepage": "https://aws.amazon.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||||
|
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||||
|
"keywords": [
|
||||||
|
"amazon",
|
||||||
|
"aws",
|
||||||
|
"cloud",
|
||||||
|
"dynamodb",
|
||||||
|
"ec2",
|
||||||
|
"glacier",
|
||||||
|
"s3",
|
||||||
|
"sdk"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||||
|
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||||
|
"source": "https://github.com/aws/aws-sdk-php/tree/3.373.2"
|
||||||
|
},
|
||||||
|
"time": "2026-03-13T18:08:30+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
"version": "0.14.8",
|
"version": "0.14.8",
|
||||||
@@ -1733,6 +1884,61 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-02-25T17:01:41+00:00"
|
"time": "2026-02-25T17:01:41+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "league/flysystem-aws-s3-v3",
|
||||||
|
"version": "3.32.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||||
|
"reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0",
|
||||||
|
"reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"aws/aws-sdk-php": "^3.295.10",
|
||||||
|
"league/flysystem": "^3.10.0",
|
||||||
|
"league/mime-type-detection": "^1.0.0",
|
||||||
|
"php": "^8.0.2"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"guzzlehttp/guzzle": "<7.0",
|
||||||
|
"guzzlehttp/ringphp": "<1.1.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\Flysystem\\AwsS3V3\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Frank de Jonge",
|
||||||
|
"email": "info@frankdejonge.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||||
|
"keywords": [
|
||||||
|
"Flysystem",
|
||||||
|
"aws",
|
||||||
|
"file",
|
||||||
|
"files",
|
||||||
|
"filesystem",
|
||||||
|
"s3",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0"
|
||||||
|
},
|
||||||
|
"time": "2026-02-25T16:46:44+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "league/flysystem-local",
|
"name": "league/flysystem-local",
|
||||||
"version": "3.31.0",
|
"version": "3.31.0",
|
||||||
@@ -2123,6 +2329,72 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-02T08:56:05+00:00"
|
"time": "2026-01-02T08:56:05+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "mtdowling/jmespath.php",
|
||||||
|
"version": "2.8.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||||
|
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||||
|
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2.5 || ^8.0",
|
||||||
|
"symfony/polyfill-mbstring": "^1.17"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"composer/xdebug-handler": "^3.0.3",
|
||||||
|
"phpunit/phpunit": "^8.5.33"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"bin/jp.php"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.8-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/JmesPath.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"JmesPath\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Graham Campbell",
|
||||||
|
"email": "hello@gjcampbell.co.uk",
|
||||||
|
"homepage": "https://github.com/GrahamCampbell"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Michael Dowling",
|
||||||
|
"email": "mtdowling@gmail.com",
|
||||||
|
"homepage": "https://github.com/mtdowling"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||||
|
"keywords": [
|
||||||
|
"json",
|
||||||
|
"jsonpath"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||||
|
"source": "https://github.com/jmespath/jmespath.php/tree/2.8.0"
|
||||||
|
},
|
||||||
|
"time": "2024-09-04T18:46:31+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "nesbot/carbon",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.11.2",
|
"version": "3.11.2",
|
||||||
@@ -3850,6 +4122,76 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-09-25T14:21:43+00:00"
|
"time": "2024-09-25T14:21:43+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/filesystem",
|
||||||
|
"version": "v7.4.6",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/filesystem.git",
|
||||||
|
"reference": "3ebc794fa5315e59fd122561623c2e2e4280538e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e",
|
||||||
|
"reference": "3ebc794fa5315e59fd122561623c2e2e4280538e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"symfony/polyfill-ctype": "~1.8",
|
||||||
|
"symfony/polyfill-mbstring": "~1.8"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/process": "^6.4|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Filesystem\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides basic utilities for the filesystem",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/filesystem/tree/v7.4.6"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-02-25T16:50:00+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/finder",
|
"name": "symfony/finder",
|
||||||
"version": "v7.4.6",
|
"version": "v7.4.6",
|
||||||
@@ -8395,5 +8737,8 @@
|
|||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"platform-overrides": {
|
||||||
|
"php": "8.3.30"
|
||||||
|
},
|
||||||
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-14
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -17,7 +15,7 @@ return [
|
|||||||
|
|
||||||
'defaults' => [
|
'defaults' => [
|
||||||
'guard' => env('AUTH_GUARD', 'web'),
|
'guard' => env('AUTH_GUARD', 'web'),
|
||||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
'passwords' => env('AUTH_PASSWORD_BROKER', 'game_accounts'),
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -40,7 +38,7 @@ return [
|
|||||||
'guards' => [
|
'guards' => [
|
||||||
'web' => [
|
'web' => [
|
||||||
'driver' => 'session',
|
'driver' => 'session',
|
||||||
'provider' => 'users',
|
'provider' => 'game_accounts',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -62,15 +60,9 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'game_accounts' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'azerothcore',
|
||||||
'model' => env('AUTH_MODEL', User::class),
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// 'users' => [
|
|
||||||
// 'driver' => 'database',
|
|
||||||
// 'table' => 'users',
|
|
||||||
// ],
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -93,8 +85,8 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'passwords' => [
|
'passwords' => [
|
||||||
'users' => [
|
'game_accounts' => [
|
||||||
'provider' => 'users',
|
'provider' => 'game_accounts',
|
||||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
'expire' => 60,
|
'expire' => 60,
|
||||||
'throttle' => 60,
|
'throttle' => 60,
|
||||||
|
|||||||
@@ -84,6 +84,26 @@ return [
|
|||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'azerothcore_characters' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('AZEROTHCORE_CHARACTERS_DB_URL'),
|
||||||
|
'host' => env('AZEROTHCORE_CHARACTERS_DB_HOST', env('AZEROTHCORE_AUTH_DB_HOST', '127.0.0.1')),
|
||||||
|
'port' => env('AZEROTHCORE_CHARACTERS_DB_PORT', env('AZEROTHCORE_AUTH_DB_PORT', '3306')),
|
||||||
|
'database' => env('AZEROTHCORE_CHARACTERS_DB_DATABASE', 'acore_characters'),
|
||||||
|
'username' => env('AZEROTHCORE_CHARACTERS_DB_USERNAME', env('AZEROTHCORE_AUTH_DB_USERNAME', 'root')),
|
||||||
|
'password' => env('AZEROTHCORE_CHARACTERS_DB_PASSWORD', env('AZEROTHCORE_AUTH_DB_PASSWORD', 'password')),
|
||||||
|
'unix_socket' => env('AZEROTHCORE_CHARACTERS_DB_SOCKET', ''),
|
||||||
|
'charset' => env('AZEROTHCORE_CHARACTERS_DB_CHARSET', env('AZEROTHCORE_AUTH_DB_CHARSET', 'utf8mb4')),
|
||||||
|
'collation' => env('AZEROTHCORE_CHARACTERS_DB_COLLATION', env('AZEROTHCORE_AUTH_DB_COLLATION', 'utf8mb4_unicode_ci')),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('AZEROTHCORE_CHARACTERS_DB_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
'mariadb' => [
|
'mariadb' => [
|
||||||
'driver' => 'mariadb',
|
'driver' => 'mariadb',
|
||||||
'url' => env('DB_URL'),
|
'url' => env('DB_URL'),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'auth_connection' => env('AZEROTHCORE_AUTH_CONNECTION', 'azerothcore_auth'),
|
'auth_connection' => env('AZEROTHCORE_AUTH_CONNECTION', 'azerothcore_auth'),
|
||||||
|
'characters_connection' => env('AZEROTHCORE_CHARACTERS_CONNECTION', 'azerothcore_characters'),
|
||||||
|
|
||||||
'registration' => [
|
'registration' => [
|
||||||
'gmlevel' => (int) env('AZEROTHCORE_ACCOUNT_GMLEVEL', 0),
|
'gmlevel' => (int) env('AZEROTHCORE_ACCOUNT_GMLEVEL', 0),
|
||||||
@@ -11,6 +12,17 @@ return [
|
|||||||
'enforce_unique_email' => (bool) env('AZEROTHCORE_ENFORCE_UNIQUE_EMAIL', false),
|
'enforce_unique_email' => (bool) env('AZEROTHCORE_ENFORCE_UNIQUE_EMAIL', false),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'portal' => [
|
||||||
|
'admin_min_gm_level' => (int) env('MOONWELL_ADMIN_MIN_GMLEVEL', 3),
|
||||||
|
'access_comment' => env('MOONWELL_ADMIN_ACCESS_COMMENT', 'updated via moonwell-web admin'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'client' => [
|
||||||
|
'disk' => env('MOONWELL_CLIENT_DISK', 's3'),
|
||||||
|
'object_key' => env('MOONWELL_CLIENT_OBJECT_KEY', 'World of Warcraft.zip'),
|
||||||
|
'temporary_url_minutes' => (int) env('MOONWELL_CLIENT_URL_TTL', 30),
|
||||||
|
],
|
||||||
|
|
||||||
'realm' => [
|
'realm' => [
|
||||||
'server_name' => env('MOONWELL_SERVER_NAME', 'Moonwell'),
|
'server_name' => env('MOONWELL_SERVER_NAME', 'Moonwell'),
|
||||||
'realm_name' => env('MOONWELL_REALM_NAME', 'Moonwell'),
|
'realm_name' => env('MOONWELL_REALM_NAME', 'Moonwell'),
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('invite_codes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('code', 32)->unique();
|
||||||
|
$table->unsignedInteger('created_by_account_id')->nullable()->index();
|
||||||
|
$table->string('created_by_username', 32)->nullable();
|
||||||
|
$table->unsignedInteger('redeemed_by_account_id')->nullable()->index();
|
||||||
|
$table->string('redeemed_by_username', 32)->nullable();
|
||||||
|
$table->timestamp('redeemed_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('invite_codes');
|
||||||
|
}
|
||||||
|
};
|
||||||
+176
-4
@@ -134,6 +134,19 @@ pre {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-user {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.25fr 0.85fr;
|
grid-template-columns: 1.25fr 0.85fr;
|
||||||
@@ -237,7 +250,10 @@ pre {
|
|||||||
.highlight-grid,
|
.highlight-grid,
|
||||||
.pillar-grid,
|
.pillar-grid,
|
||||||
.adventure-grid,
|
.adventure-grid,
|
||||||
.steps-grid {
|
.steps-grid,
|
||||||
|
.portal-grid,
|
||||||
|
.character-grid,
|
||||||
|
.account-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
@@ -437,6 +453,10 @@ pre {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.feature-card {
|
.feature-card {
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
}
|
}
|
||||||
@@ -487,6 +507,144 @@ pre {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin: 18px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list--compact {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list dt,
|
||||||
|
.stats-list dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list dd {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-stack,
|
||||||
|
.account-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-card {
|
||||||
|
position: relative;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-card__top,
|
||||||
|
.account-card__header,
|
||||||
|
.character-row,
|
||||||
|
.search-form,
|
||||||
|
.inline-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-card__top {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-card h3,
|
||||||
|
.account-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-card p,
|
||||||
|
.account-card p,
|
||||||
|
.character-row span,
|
||||||
|
.empty-inline {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag,
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid rgba(241, 200, 121, 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(241, 200, 121, 0.08);
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form .form-control {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
margin-top: 20px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form .form-field {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-card,
|
||||||
|
.character-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-list {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-row {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-row strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -499,14 +657,16 @@ pre {
|
|||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
.hero,
|
.hero,
|
||||||
.section--split {
|
.section--split,
|
||||||
|
.portal-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pillar-grid,
|
.pillar-grid,
|
||||||
.adventure-grid,
|
.adventure-grid,
|
||||||
.steps-grid,
|
.steps-grid,
|
||||||
.highlight-grid {
|
.highlight-grid,
|
||||||
|
.character-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -526,6 +686,16 @@ pre {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions,
|
||||||
|
.search-form,
|
||||||
|
.inline-form,
|
||||||
|
.character-card__top,
|
||||||
|
.account-card__header,
|
||||||
|
.character-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
padding-top: 18px;
|
padding-top: 18px;
|
||||||
}
|
}
|
||||||
@@ -550,7 +720,9 @@ pre {
|
|||||||
.adventure-grid,
|
.adventure-grid,
|
||||||
.steps-grid,
|
.steps-grid,
|
||||||
.highlight-grid,
|
.highlight-grid,
|
||||||
.server-card dl {
|
.server-card dl,
|
||||||
|
.stats-list,
|
||||||
|
.character-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
@extends('layouts.portal')
|
||||||
|
|
||||||
|
@section('portal-content')
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Инвайты</span>
|
||||||
|
<h2>Управление инвайт-кодами</h2>
|
||||||
|
<p>Без действующего инвайт-кода регистрация нового аккаунта недоступна.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portal-grid">
|
||||||
|
<article class="content-card">
|
||||||
|
<h3>Создать инвайт-коды</h3>
|
||||||
|
|
||||||
|
<dl class="stats-list stats-list--compact">
|
||||||
|
<div>
|
||||||
|
<dt>Всего</dt>
|
||||||
|
<dd>{{ $inviteStats['total'] }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Доступно</dt>
|
||||||
|
<dd>{{ $inviteStats['available'] }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Использовано</dt>
|
||||||
|
<dd>{{ $inviteStats['redeemed'] }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<form class="inline-form" method="POST" action="{{ route('admin.invite-codes.store') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Количество</span>
|
||||||
|
<input class="form-control @error('quantity') is-invalid @enderror" type="number" min="1" max="50" name="quantity" value="{{ old('quantity', 1) }}" required>
|
||||||
|
@error('quantity')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="button button--gold" type="submit">Создать коды</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="content-card">
|
||||||
|
<h3>Последние инвайт-коды</h3>
|
||||||
|
|
||||||
|
@if ($inviteCodes->isNotEmpty())
|
||||||
|
<div class="character-list">
|
||||||
|
@foreach ($inviteCodes as $invite)
|
||||||
|
<div class="character-row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ $invite->code }}</strong>
|
||||||
|
<span>
|
||||||
|
Создал: {{ $invite->created_by_username ?: '—' }}
|
||||||
|
@if ($invite->created_at)
|
||||||
|
· {{ $invite->created_at->format('d.m.Y H:i') }}
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ $invite->redeemed_at ? 'Использован' : 'Свободен' }}</strong>
|
||||||
|
<span>
|
||||||
|
{{ $invite->redeemed_by_username ?: 'ещё не использован' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="empty-inline">Инвайт-коды ещё не создавались.</div>
|
||||||
|
@endif
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Админка</span>
|
||||||
|
<h2>Аккаунты и персонажи</h2>
|
||||||
|
<p>Здесь видны все игровые аккаунты, их персонажи и текущий уровень доступа.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="search-form" method="GET" action="{{ route('admin.accounts.index') }}">
|
||||||
|
<input class="form-control" type="search" name="search" value="{{ $search }}" placeholder="Поиск по логину или email">
|
||||||
|
<button class="button button--gold" type="submit">Найти</button>
|
||||||
|
@if ($search !== '')
|
||||||
|
<a class="button button--ghost" href="{{ route('admin.accounts.index') }}">Сбросить</a>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="account-list">
|
||||||
|
@forelse ($accounts as $account)
|
||||||
|
<article class="content-card account-card">
|
||||||
|
<div class="account-card__header">
|
||||||
|
<div>
|
||||||
|
<h3>{{ $account['username'] }}</h3>
|
||||||
|
<p>ID #{{ $account['id'] }} · {{ $account['email'] !== '' ? $account['email'] : 'email не указан' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="tag">{{ $account['access_label'] }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="stats-list stats-list--compact">
|
||||||
|
<div>
|
||||||
|
<dt>Регистрация</dt>
|
||||||
|
<dd>{{ $account['joined_at'] }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Последний вход</dt>
|
||||||
|
<dd>{{ $account['last_login_at'] }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Статус</dt>
|
||||||
|
<dd>{{ $account['locked'] ? 'Заблокирован' : 'Активен' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Персонажей</dt>
|
||||||
|
<dd>{{ $account['characters']->count() }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<form class="inline-form" method="POST" action="{{ route('admin.accounts.access.update', $account['id']) }}">
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Уровень доступа</span>
|
||||||
|
<select class="form-control @error('gmlevel') is-invalid @enderror" name="gmlevel">
|
||||||
|
@foreach ($accessLevels as $level => $label)
|
||||||
|
<option value="{{ $level }}" @selected($account['gm_level'] === $level)>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="button button--gold" type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if ($account['characters']->isNotEmpty())
|
||||||
|
<div class="character-list">
|
||||||
|
@foreach ($account['characters'] as $character)
|
||||||
|
<div class="character-row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ $character['name'] }}</strong>
|
||||||
|
<span>{{ $character['race'] }} · {{ $character['class'] }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ $character['level'] }} ур.</strong>
|
||||||
|
<span>{{ $character['online'] ? 'Онлайн' : 'Оффлайн' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="empty-inline">На аккаунте пока нет персонажей.</div>
|
||||||
|
@endif
|
||||||
|
</article>
|
||||||
|
@empty
|
||||||
|
<article class="content-card empty-state">
|
||||||
|
<h3>Аккаунты не найдены</h3>
|
||||||
|
<p>Измени поисковый запрос или попробуй позже.</p>
|
||||||
|
</article>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
@extends('layouts.portal')
|
||||||
|
|
||||||
|
@section('portal-content')
|
||||||
|
<section class="section section--split">
|
||||||
|
<article class="content-card">
|
||||||
|
<span class="eyebrow">Личный кабинет</span>
|
||||||
|
<h2>Вход в игровой аккаунт</h2>
|
||||||
|
<p>
|
||||||
|
Используй логин и пароль от игрового аккаунта. После входа будут доступны персонажи,
|
||||||
|
смена пароля и ссылка на клиент игры.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="feature-points">
|
||||||
|
<li>Просмотр всех персонажей аккаунта</li>
|
||||||
|
<li>Смена пароля без ручных команд</li>
|
||||||
|
<li>Быстрый переход к клиенту игры</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside class="hero-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="eyebrow">Авторизация</span>
|
||||||
|
<h2>Войти</h2>
|
||||||
|
<p>Вход выполняется по игровым учётным данным.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="register-form" method="POST" action="{{ route('login.store') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Логин</span>
|
||||||
|
<input
|
||||||
|
class="form-control @error('username') is-invalid @enderror"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
value="{{ old('username') }}"
|
||||||
|
autocomplete="username"
|
||||||
|
maxlength="32"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
@error('username')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Пароль</span>
|
||||||
|
<input
|
||||||
|
class="form-control @error('password') is-invalid @enderror"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
maxlength="32"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
@error('password')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="button button--gold button--full" type="submit">Войти в кабинет</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
@extends('layouts.portal')
|
||||||
|
|
||||||
|
@section('portal-content')
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Личный кабинет</span>
|
||||||
|
<h2>{{ $user->username }}</h2>
|
||||||
|
<p>Здесь можно посмотреть персонажей, сменить пароль и получить ссылку на клиент игры.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portal-grid">
|
||||||
|
<article class="content-card">
|
||||||
|
<h3>Данные аккаунта</h3>
|
||||||
|
|
||||||
|
<dl class="stats-list">
|
||||||
|
<div>
|
||||||
|
<dt>Логин</dt>
|
||||||
|
<dd>{{ $user->username }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>{{ $user->email !== '' ? $user->email : '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Уровень доступа</dt>
|
||||||
|
<dd>{{ $user->accessLabel() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Регистрация</dt>
|
||||||
|
<dd>{{ $user->joinedAtLabel() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Последний вход</dt>
|
||||||
|
<dd>{{ $user->lastLoginLabel() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Статус</dt>
|
||||||
|
<dd>{{ $user->locked ? 'Заблокирован' : 'Активен' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@if ($user->canAccessAdminPanel())
|
||||||
|
<a class="button button--ghost" href="{{ route('admin.accounts.index') }}">Перейти в админку</a>
|
||||||
|
@endif
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="content-card">
|
||||||
|
<h3>Клиент игры</h3>
|
||||||
|
<p>Ссылка формируется при нажатии. Файл выдаётся напрямую из хранилища.</p>
|
||||||
|
|
||||||
|
<div class="action-stack">
|
||||||
|
<a class="button button--gold" href="{{ route('cabinet.client') }}">Получить ссылку на клиент</a>
|
||||||
|
<span class="muted-note">Файл: {{ $clientFilename }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section section--split">
|
||||||
|
<article class="content-card">
|
||||||
|
<span class="eyebrow">Безопасность</span>
|
||||||
|
<h2>Сменить пароль</h2>
|
||||||
|
<p>После обновления новый пароль сразу начнёт действовать для входа в игру и кабинет.</p>
|
||||||
|
|
||||||
|
<form class="register-form" method="POST" action="{{ route('cabinet.password.update') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Текущий пароль</span>
|
||||||
|
<input class="form-control @error('current_password') is-invalid @enderror" type="password" name="current_password" maxlength="32" required>
|
||||||
|
@error('current_password')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Новый пароль</span>
|
||||||
|
<input class="form-control @error('password') is-invalid @enderror" type="password" name="password" maxlength="32" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Подтверждение нового пароля</span>
|
||||||
|
<input class="form-control @error('password') is-invalid @enderror" type="password" name="password_confirmation" maxlength="32" required>
|
||||||
|
@error('password')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="button button--gold button--full" type="submit">Обновить пароль</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="content-card">
|
||||||
|
<span class="eyebrow">Персонажи</span>
|
||||||
|
<h2>Краткая сводка</h2>
|
||||||
|
<p>Всего персонажей: {{ $characters->count() }}. Ниже показан полный список по этому аккаунту.</p>
|
||||||
|
|
||||||
|
@if ($characters->isNotEmpty())
|
||||||
|
<div class="tag-list">
|
||||||
|
@foreach ($characters as $character)
|
||||||
|
<span class="tag">{{ $character['name'] }} · {{ $character['class'] }} · {{ $character['level'] }}</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="empty-inline">Персонажей пока нет.</div>
|
||||||
|
@endif
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Мои персонажи</span>
|
||||||
|
<h2>Все персонажи аккаунта</h2>
|
||||||
|
<p>Здесь отображаются игровые персонажи, связанные с текущим аккаунтом.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="character-grid">
|
||||||
|
@forelse ($characters as $character)
|
||||||
|
<article class="character-card">
|
||||||
|
<div class="character-card__top">
|
||||||
|
<div>
|
||||||
|
<h3>{{ $character['name'] }}</h3>
|
||||||
|
<p>{{ $character['race'] }} · {{ $character['class'] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="tag">{{ $character['online'] ? 'Онлайн' : 'Оффлайн' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="stats-list stats-list--compact">
|
||||||
|
<div>
|
||||||
|
<dt>Уровень</dt>
|
||||||
|
<dd>{{ $character['level'] }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Пол</dt>
|
||||||
|
<dd>{{ $character['gender'] }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Золото</dt>
|
||||||
|
<dd>{{ $character['money'] }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Наработано</dt>
|
||||||
|
<dd>{{ $character['played'] }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
@empty
|
||||||
|
<article class="content-card empty-state">
|
||||||
|
<h3>Персонажей пока нет</h3>
|
||||||
|
<p>Создай персонажа в игре, и он появится здесь автоматически.</p>
|
||||||
|
</article>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -17,6 +17,18 @@
|
|||||||
<a href="#start">Как начать</a>
|
<a href="#start">Как начать</a>
|
||||||
<a href="#register">Регистрация</a>
|
<a href="#register">Регистрация</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
@auth
|
||||||
|
<a class="button button--ghost" href="{{ route('cabinet.index') }}">Личный кабинет</a>
|
||||||
|
|
||||||
|
@if (auth()->user()->canAccessAdminPanel())
|
||||||
|
<a class="button button--ghost" href="{{ route('admin.accounts.index') }}">Админка</a>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<a class="button button--ghost" href="{{ route('login') }}">Войти</a>
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -49,7 +61,7 @@
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="eyebrow">Регистрация</span>
|
<span class="eyebrow">Регистрация</span>
|
||||||
<h2>Создать игровой аккаунт</h2>
|
<h2>Создать игровой аккаунт</h2>
|
||||||
<p>Укажи логин, почту и пароль — аккаунт будет готов к входу в игру сразу после регистрации.</p>
|
<p>Укажи инвайт-код, логин, почту и пароль — аккаунт будет готов к входу в игру сразу после регистрации.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (session('registration_success'))
|
@if (session('registration_success'))
|
||||||
@@ -94,6 +106,22 @@
|
|||||||
@error('email')<span class="form-error">{{ $message }}</span>@enderror
|
@error('email')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Инвайт-код</span>
|
||||||
|
<input
|
||||||
|
class="form-control @error('invite_code') is-invalid @enderror"
|
||||||
|
type="text"
|
||||||
|
name="invite_code"
|
||||||
|
value="{{ old('invite_code') }}"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="ABCD-EFGH-IJKL"
|
||||||
|
maxlength="32"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<small>Регистрация доступна только по действующему инвайт-коду.</small>
|
||||||
|
@error('invite_code')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>Пароль</span>
|
<span>Пароль</span>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php($portalUser = auth()->user())
|
||||||
|
|
||||||
|
<div class="page-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="{{ route('landing.index') }}">
|
||||||
|
<span class="brand-mark"></span>
|
||||||
|
<span>
|
||||||
|
<strong>{{ config('moonwell.realm.server_name') }}</strong>
|
||||||
|
<small>{{ config('moonwell.realm.tagline') }}</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="topnav">
|
||||||
|
<a href="{{ route('landing.index') }}">Главная</a>
|
||||||
|
@if ($portalUser)
|
||||||
|
<a href="{{ route('cabinet.index') }}">Личный кабинет</a>
|
||||||
|
@if ($portalUser->canAccessAdminPanel())
|
||||||
|
<a href="{{ route('admin.accounts.index') }}">Админка</a>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
@if ($portalUser)
|
||||||
|
<span class="topbar-user">{{ $portalUser->username }} · {{ $portalUser->accessLabel() }}</span>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
|
@csrf
|
||||||
|
<button class="button button--ghost" type="submit">Выйти</button>
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
<a class="button button--ghost" href="{{ route('login') }}">Войти</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (session('status'))
|
||||||
|
<div class="alert alert--success">{{ session('status') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (session('error'))
|
||||||
|
<div class="alert alert--error">{{ session('error') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<main>
|
||||||
|
@yield('portal-content')
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\AccountController;
|
||||||
|
use App\Http\Controllers\Admin\InviteCodeController;
|
||||||
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
|
use App\Http\Controllers\CabinetController;
|
||||||
use App\Http\Controllers\LandingController;
|
use App\Http\Controllers\LandingController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -7,3 +11,26 @@ Route::get('/', [LandingController::class, 'index'])->name('landing.index');
|
|||||||
Route::post('/register', [LandingController::class, 'register'])
|
Route::post('/register', [LandingController::class, 'register'])
|
||||||
->middleware('throttle:8,1')
|
->middleware('throttle:8,1')
|
||||||
->name('game-account.store');
|
->name('game-account.store');
|
||||||
|
|
||||||
|
Route::middleware('guest')->group(function (): void {
|
||||||
|
Route::get('/login', [LoginController::class, 'create'])->name('login');
|
||||||
|
Route::post('/login', [LoginController::class, 'store'])
|
||||||
|
->middleware('throttle:10,1')
|
||||||
|
->name('login.store');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware('auth')->group(function (): void {
|
||||||
|
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
|
||||||
|
Route::get('/cabinet', [CabinetController::class, 'index'])->name('cabinet.index');
|
||||||
|
Route::post('/cabinet/password', [CabinetController::class, 'updatePassword'])->name('cabinet.password.update');
|
||||||
|
Route::get('/cabinet/client', [CabinetController::class, 'client'])->name('cabinet.client');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware(['auth', 'gamemaster'])
|
||||||
|
->prefix('admin')
|
||||||
|
->name('admin.')
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('/accounts', [AccountController::class, 'index'])->name('accounts.index');
|
||||||
|
Route::patch('/accounts/{accountId}/access', [AccountController::class, 'updateAccess'])->name('accounts.access.update');
|
||||||
|
Route::post('/invite-codes', [InviteCodeController::class, 'store'])->name('invite-codes.store');
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\GameAccountUser;
|
||||||
|
use App\Services\AzerothCoreAccountService;
|
||||||
|
use App\Services\InviteCodeService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAccountsFeatureTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_guest_is_redirected_to_login_for_admin(): void
|
||||||
|
{
|
||||||
|
$response = $this->get(route('admin.accounts.index'));
|
||||||
|
|
||||||
|
$response->assertRedirect(route('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_regular_player_cannot_open_admin_accounts_page(): void
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->makeUser(0))->get(route('admin.accounts.index'));
|
||||||
|
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_open_accounts_page(): void
|
||||||
|
{
|
||||||
|
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('listAccounts')
|
||||||
|
->once()
|
||||||
|
->with(null)
|
||||||
|
->andReturn(new Collection([
|
||||||
|
[
|
||||||
|
'id' => 1,
|
||||||
|
'username' => 'ADMIN',
|
||||||
|
'email' => 'ADMIN@EXAMPLE.COM',
|
||||||
|
'reg_mail' => 'ADMIN@EXAMPLE.COM',
|
||||||
|
'gm_level' => 3,
|
||||||
|
'access_label' => 'Администратор',
|
||||||
|
'locked' => false,
|
||||||
|
'online' => false,
|
||||||
|
'joined_at' => '12.03.2026 12:00',
|
||||||
|
'last_login_at' => '13.03.2026 18:00',
|
||||||
|
'characters' => new Collection([
|
||||||
|
[
|
||||||
|
'name' => 'Stormmage',
|
||||||
|
'race' => 'Человек',
|
||||||
|
'class' => 'Маг',
|
||||||
|
'level' => 80,
|
||||||
|
'online' => false,
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('latest')
|
||||||
|
->once()
|
||||||
|
->andReturn(new Collection());
|
||||||
|
|
||||||
|
$mock->shouldReceive('stats')
|
||||||
|
->once()
|
||||||
|
->andReturn(['total' => 0, 'available' => 0, 'redeemed' => 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->makeUser(3))->get(route('admin.accounts.index'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('Stormmage');
|
||||||
|
$response->assertSee('Аккаунты и персонажи');
|
||||||
|
$response->assertSee('Управление инвайт-кодами');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_update_access_level(): void
|
||||||
|
{
|
||||||
|
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('findUserById')
|
||||||
|
->once()
|
||||||
|
->with(12)
|
||||||
|
->andReturn($this->makeUser(0));
|
||||||
|
|
||||||
|
$mock->shouldReceive('updateAccessLevel')
|
||||||
|
->once()
|
||||||
|
->with(12, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->from(route('admin.accounts.index'))
|
||||||
|
->actingAs($this->makeUser(3))
|
||||||
|
->patch(route('admin.accounts.access.update', 12), [
|
||||||
|
'gmlevel' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.accounts.index'));
|
||||||
|
$response->assertSessionHas('status', 'Уровень доступа обновлён.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_create_invite_codes(): void
|
||||||
|
{
|
||||||
|
$admin = $this->makeUser(3);
|
||||||
|
|
||||||
|
$this->mock(InviteCodeService::class, function (MockInterface $mock) use ($admin): void {
|
||||||
|
$mock->shouldReceive('createCodes')
|
||||||
|
->once()
|
||||||
|
->with(3, $admin)
|
||||||
|
->andReturn(new Collection([1, 2, 3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->from(route('admin.accounts.index'))
|
||||||
|
->actingAs($admin)
|
||||||
|
->post(route('admin.invite-codes.store'), [
|
||||||
|
'quantity' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.accounts.index'));
|
||||||
|
$response->assertSessionHas('status', 'Создано инвайт-кодов: 3.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeUser(int $gmLevel): GameAccountUser
|
||||||
|
{
|
||||||
|
return new GameAccountUser(
|
||||||
|
id: 1,
|
||||||
|
username: 'ADMIN',
|
||||||
|
email: 'ADMIN@EXAMPLE.COM',
|
||||||
|
regMail: 'ADMIN@EXAMPLE.COM',
|
||||||
|
gmLevel: $gmLevel,
|
||||||
|
locked: false,
|
||||||
|
lastLoginAt: '2026-03-13 18:00:00',
|
||||||
|
joinedAt: '2026-03-12 12:00:00',
|
||||||
|
saltHex: str_repeat('AA', 32),
|
||||||
|
verifierHex: str_repeat('BB', 32),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\GameAccountUser;
|
||||||
|
use App\Services\AzerothCoreAccountService;
|
||||||
|
use App\Services\GameClientDownloadService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CabinetFeatureTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_guest_is_redirected_to_login_for_cabinet(): void
|
||||||
|
{
|
||||||
|
$response = $this->get(route('cabinet.index'));
|
||||||
|
|
||||||
|
$response->assertRedirect(route('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authenticated_user_can_open_cabinet(): void
|
||||||
|
{
|
||||||
|
$user = $this->makeUser();
|
||||||
|
|
||||||
|
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock) use ($user): void {
|
||||||
|
$mock->shouldReceive('charactersForAccount')
|
||||||
|
->once()
|
||||||
|
->with($user->id)
|
||||||
|
->andReturn(new Collection([
|
||||||
|
[
|
||||||
|
'name' => 'Arthion',
|
||||||
|
'race' => 'Человек',
|
||||||
|
'class' => 'Паладин',
|
||||||
|
'gender' => 'Мужской',
|
||||||
|
'level' => 80,
|
||||||
|
'money' => '125з 0с 0м',
|
||||||
|
'played' => '4д 6ч',
|
||||||
|
'online' => false,
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get(route('cabinet.index'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('Arthion');
|
||||||
|
$response->assertSee('Получить ссылку на клиент');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authenticated_user_can_change_password(): void
|
||||||
|
{
|
||||||
|
$user = $this->makeUser();
|
||||||
|
|
||||||
|
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock) use ($user): void {
|
||||||
|
$mock->shouldReceive('validatePassword')
|
||||||
|
->once()
|
||||||
|
->with($user, 'OldPass123')
|
||||||
|
->andReturn(true);
|
||||||
|
|
||||||
|
$mock->shouldReceive('updatePassword')
|
||||||
|
->once()
|
||||||
|
->with($user, 'NewPass123');
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post(route('cabinet.password.update'), [
|
||||||
|
'current_password' => 'OldPass123',
|
||||||
|
'password' => 'NewPass123',
|
||||||
|
'password_confirmation' => 'NewPass123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHas('status', 'Пароль успешно обновлён.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_password_change_rejects_invalid_current_password(): void
|
||||||
|
{
|
||||||
|
$user = $this->makeUser();
|
||||||
|
|
||||||
|
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock) use ($user): void {
|
||||||
|
$mock->shouldReceive('validatePassword')
|
||||||
|
->once()
|
||||||
|
->with($user, 'WrongPass123')
|
||||||
|
->andReturn(false);
|
||||||
|
|
||||||
|
$mock->shouldNotReceive('updatePassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->from(route('cabinet.index'))->actingAs($user)->post(route('cabinet.password.update'), [
|
||||||
|
'current_password' => 'WrongPass123',
|
||||||
|
'password' => 'NewPass123',
|
||||||
|
'password_confirmation' => 'NewPass123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('cabinet.index'));
|
||||||
|
$response->assertSessionHasErrors(['current_password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authenticated_user_can_request_client_link(): void
|
||||||
|
{
|
||||||
|
$user = $this->makeUser();
|
||||||
|
|
||||||
|
$this->mock(GameClientDownloadService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('temporaryUrl')
|
||||||
|
->once()
|
||||||
|
->andReturn('https://example.com/client.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get(route('cabinet.client'));
|
||||||
|
|
||||||
|
$response->assertRedirect('https://example.com/client.zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeUser(int $gmLevel = 0): GameAccountUser
|
||||||
|
{
|
||||||
|
return new GameAccountUser(
|
||||||
|
id: 7,
|
||||||
|
username: 'PLAYERONE',
|
||||||
|
email: 'PLAYER@EXAMPLE.COM',
|
||||||
|
regMail: 'PLAYER@EXAMPLE.COM',
|
||||||
|
gmLevel: $gmLevel,
|
||||||
|
locked: false,
|
||||||
|
lastLoginAt: '2026-03-13 18:00:00',
|
||||||
|
joinedAt: '2026-03-12 12:00:00',
|
||||||
|
saltHex: str_repeat('AA', 32),
|
||||||
|
verifierHex: str_repeat('BB', 32),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,10 @@
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Exceptions\DuplicateGameAccountException;
|
use App\Exceptions\DuplicateGameAccountException;
|
||||||
|
use App\Exceptions\InviteCodeException;
|
||||||
use App\Services\AzerothCoreAccountRegistrar;
|
use App\Services\AzerothCoreAccountRegistrar;
|
||||||
|
use App\Services\InviteCodeService;
|
||||||
|
use Closure;
|
||||||
use Mockery\MockInterface;
|
use Mockery\MockInterface;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -11,6 +14,13 @@ class GameAccountRegistrationTest extends TestCase
|
|||||||
{
|
{
|
||||||
public function test_registration_form_submits_successfully(): void
|
public function test_registration_form_submits_successfully(): void
|
||||||
{
|
{
|
||||||
|
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('redeemForRegistration')
|
||||||
|
->once()
|
||||||
|
->with('INVITE-1234', 'playerone', \Mockery::type(Closure::class))
|
||||||
|
->andReturnUsing(fn (string $code, string $username, Closure $callback): array => $callback());
|
||||||
|
});
|
||||||
|
|
||||||
$this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void {
|
$this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void {
|
||||||
$mock->shouldReceive('register')
|
$mock->shouldReceive('register')
|
||||||
->once()
|
->once()
|
||||||
@@ -21,6 +31,7 @@ class GameAccountRegistrationTest extends TestCase
|
|||||||
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
|
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
|
||||||
'username' => 'playerone',
|
'username' => 'playerone',
|
||||||
'email' => 'player@example.com',
|
'email' => 'player@example.com',
|
||||||
|
'invite_code' => 'INVITE-1234',
|
||||||
'password' => 'Pass1234',
|
'password' => 'Pass1234',
|
||||||
'password_confirmation' => 'Pass1234',
|
'password_confirmation' => 'Pass1234',
|
||||||
'terms' => '1',
|
'terms' => '1',
|
||||||
@@ -35,17 +46,24 @@ class GameAccountRegistrationTest extends TestCase
|
|||||||
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
|
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
|
||||||
'username' => 'игрок!',
|
'username' => 'игрок!',
|
||||||
'email' => 'not-an-email',
|
'email' => 'not-an-email',
|
||||||
|
'invite_code' => '',
|
||||||
'password' => '123',
|
'password' => '123',
|
||||||
'password_confirmation' => '456',
|
'password_confirmation' => '456',
|
||||||
'terms' => '',
|
'terms' => '',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertRedirect(route('landing.index'));
|
$response->assertRedirect(route('landing.index'));
|
||||||
$response->assertSessionHasErrors(['username', 'email', 'password', 'terms']);
|
$response->assertSessionHasErrors(['username', 'email', 'invite_code', 'password', 'terms']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_duplicate_username_is_reported_back_to_the_form(): void
|
public function test_duplicate_username_is_reported_back_to_the_form(): void
|
||||||
{
|
{
|
||||||
|
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('redeemForRegistration')
|
||||||
|
->once()
|
||||||
|
->andReturnUsing(fn (string $code, string $username, Closure $callback): array => $callback());
|
||||||
|
});
|
||||||
|
|
||||||
$this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void {
|
$this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void {
|
||||||
$mock->shouldReceive('register')
|
$mock->shouldReceive('register')
|
||||||
->once()
|
->once()
|
||||||
@@ -55,6 +73,7 @@ class GameAccountRegistrationTest extends TestCase
|
|||||||
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
|
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
|
||||||
'username' => 'playerone',
|
'username' => 'playerone',
|
||||||
'email' => 'player@example.com',
|
'email' => 'player@example.com',
|
||||||
|
'invite_code' => 'INVITE-1234',
|
||||||
'password' => 'Pass1234',
|
'password' => 'Pass1234',
|
||||||
'password_confirmation' => 'Pass1234',
|
'password_confirmation' => 'Pass1234',
|
||||||
'terms' => '1',
|
'terms' => '1',
|
||||||
@@ -63,4 +82,25 @@ class GameAccountRegistrationTest extends TestCase
|
|||||||
$response->assertRedirect(route('landing.index'));
|
$response->assertRedirect(route('landing.index'));
|
||||||
$response->assertSessionHasErrors(['username']);
|
$response->assertSessionHasErrors(['username']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_invalid_invite_code_is_reported_back_to_the_form(): void
|
||||||
|
{
|
||||||
|
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('redeemForRegistration')
|
||||||
|
->once()
|
||||||
|
->andThrow(InviteCodeException::invalid());
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
|
||||||
|
'username' => 'playerone',
|
||||||
|
'email' => 'player@example.com',
|
||||||
|
'invite_code' => 'WRONG-CODE',
|
||||||
|
'password' => 'Pass1234',
|
||||||
|
'password_confirmation' => 'Pass1234',
|
||||||
|
'terms' => '1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('landing.index'));
|
||||||
|
$response->assertSessionHasErrors(['invite_code']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user