Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class AccountController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class AdminController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function settings()
{
return view('profile-admin.settings');
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Http\Controllers;
use App\Events\ProfileSwitchEvent;
use App\Models\Admin;
use App\Models\User;
use App\Traits\SwitchGuardTrait;
use Hash;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AdminLoginController extends Controller
{
use SwitchGuardTrait;
/**
* Direct link to admin login - can be used in emails
* Handles the layered authentication flow:
* 1. If user not authenticated -> redirect to user login first
* 2. If user authenticated -> set intent and redirect to admin login
* 3. After admin login -> redirect to intended URL or main page
*/
public function directLogin(Request $request, $adminId)
{
// Validate admin exists and load users
$admin = Admin::with('users')->find($adminId);
if (!$admin) {
abort(404, __('Admin not found'));
}
// Get optional intended destination after successful admin login
$intendedUrl = $request->query('intended');
// Store intended URL in session if provided
if ($intendedUrl) {
session(['admin_login_intended_url' => $intendedUrl]);
}
// Check if user is authenticated on web guard
if (!Auth::guard('web')->check()) {
// User not logged in - redirect to user login with return URL
$returnUrl = route('admin.direct-login', ['adminId' => $adminId]);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
// Store in session for Laravel to redirect after login
session()->put('url.intended', $returnUrl);
// Get the first user associated with this admin to pre-populate the login form
$firstUser = $admin->users()->first();
if ($firstUser) {
// Build the login URL with the name parameter and localization
$loginRoute = route('login') . '?name=' . urlencode($firstUser->name);
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
return redirect($loginUrl);
}
return redirect()->route('login');
}
// User is authenticated - verify they have this admin profile
$user = Auth::guard('web')->user();
$userWithRelations = User::with('admins')->find($user->id);
if (!$userWithRelations || !$userWithRelations->admins->contains('id', $adminId)) {
abort(403, __('You do not have access to this admin profile'));
}
// Set the profile switch intent in session
session([
'intended_profile_switch_type' => 'Admin',
'intended_profile_switch_id' => $adminId,
]);
// Redirect to admin login form
return redirect()->route('admin.login');
}
public function showLoginForm()
{
$user = Auth::guard('web')->user();
$type = session('intended_profile_switch_type');
$id = session('intended_profile_switch_id');
$profile = $this->getTargetProfileByTypeAndId($user, $type, $id);
return view('profile-admin.login', ['profile' => $profile]);
}
public function login(Request $request)
{
$request->validate([
'password' => 'required',
]);
$user = Auth::guard('web')->user();
$type = session('intended_profile_switch_type');
$id = session('intended_profile_switch_id');
$admin = $this->getTargetProfileByTypeAndId($user, $type, $id);
if (!$admin) {
return back()->withErrors(['index' => __('Admin not found')]);
}
// Legacy Cyclos password support
if (!empty($admin->cyclos_salt)) {
info('Auth attempt using original Cyclos password');
$concatenated = $admin->cyclos_salt . $request->password;
$hashedInputPassword = hash("sha256", $concatenated);
if (strtolower($hashedInputPassword) === strtolower($admin->password)) {
info('Auth success: Password is verified');
// Rehash to Laravel hash and remove salt
$admin->password = \Hash::make($request->password);
$admin->cyclos_salt = null;
$admin->save();
info('Auth success: Cyclos password has been rehashed for next login');
}
}
// Check if the provided password matches the hashed password in the database
if (\Hash::check($request->password, $admin->password)) {
$this->switchGuard('admin', $admin); // log in as admin
// Remove intended switch from session
session()->forget(['intended_profile_switch_type', 'intended_profile_switch_id']);
// Set active profile session as before
session([
'activeProfileType' => get_class($admin),
'activeProfileId' => $admin->id,
'activeProfileName' => $admin->name,
'activeProfilePhoto' => $admin->profile_photo_path,
'last_activity' => now(),
'profile-switched-notification' => true,
]);
// Re-activate profile if inactive
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$admin->isActive()) {
$admin->inactive_at = null;
$admin->save();
info('Admin re-activated: ' . $admin->name);
}
}
event(new \App\Events\ProfileSwitchEvent($admin));
// Check for intended URL from direct login flow
$intendedUrl = session('admin_login_intended_url');
if ($intendedUrl) {
session()->forget('admin_login_intended_url');
return redirect($intendedUrl);
}
return redirect()->route('main'); // Or your special admin main page
}
info('Auth failed: Input password does not match stored password');
return back()->withErrors(['password' => __('Invalid admin password')]);
}
//TODO: Move to Trait as suggested below.
/**
* Helper method to get the target profile model based on the index.
* Note: This duplicates logic from ProfileSelect::mount. Consider refactoring
* this logic into a service or trait if used in multiple places.
*/
private function getTargetProfileByTypeAndId($user, $type, $id)
{
if (!$type || !$id) {
return null;
}
$userWithRelations = User::with(['organizations', 'banksManaged', 'admins'])->find($user->id);
if (!$userWithRelations) return null;
if (strtolower($type) === 'organization') {
return $userWithRelations->organizations->firstWhere('id', $id);
} elseif (strtolower($type) === 'bank') {
return $userWithRelations->banksManaged->firstWhere('id', $id);
} elseif (strtolower($type) === 'admin') {
return $userWithRelations->admins->firstWhere('id', $id);
}
return null;
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\ProfileAuthorizationHelper;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use ZipArchive;
class BackupChunkUploadController extends Controller
{
/**
* Receive a single chunk of the backup file.
* POST /posts/backup-upload/chunk
*/
public function uploadChunk(Request $request): JsonResponse
{
$this->authorizeAdminAccess();
$request->validate([
'uploadId' => 'required|string|uuid',
'chunkIndex' => 'required|integer|min:0|max:999',
'totalChunks' => 'required|integer|min:1|max:1000',
'chunk' => 'required|file|max:3072', // 3MB max per chunk
]);
$uploadId = $request->input('uploadId');
$chunkIndex = (int) $request->input('chunkIndex');
$totalChunks = (int) $request->input('totalChunks');
if ($chunkIndex >= $totalChunks) {
return response()->json(['error' => 'Invalid chunk index'], 422);
}
$chunkDir = storage_path("app/temp/chunks/{$uploadId}");
if (!File::isDirectory($chunkDir)) {
File::makeDirectory($chunkDir, 0755, true);
}
$request->file('chunk')->move($chunkDir, "chunk_{$chunkIndex}");
return response()->json([
'success' => true,
'chunkIndex' => $chunkIndex,
'received' => count(File::files($chunkDir)),
'total' => $totalChunks,
]);
}
/**
* Finalize: reassemble chunks into the complete file.
* POST /posts/backup-upload/finalize
*/
public function finalize(Request $request): JsonResponse
{
$this->authorizeAdminAccess();
$request->validate([
'uploadId' => 'required|string|uuid',
'totalChunks' => 'required|integer|min:1|max:1000',
'fileName' => 'required|string|max:255',
]);
$uploadId = $request->input('uploadId');
$totalChunks = (int) $request->input('totalChunks');
$fileName = basename($request->input('fileName'));
$chunkDir = storage_path("app/temp/chunks/{$uploadId}");
$outputDir = storage_path('app/temp');
if (!File::isDirectory($outputDir)) {
File::makeDirectory($outputDir, 0755, true);
}
// Verify all chunks exist
for ($i = 0; $i < $totalChunks; $i++) {
if (!File::exists("{$chunkDir}/chunk_{$i}")) {
return response()->json(['error' => "Missing chunk {$i}"], 422);
}
}
// Reassemble
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if (!in_array($ext, ['zip', 'json'])) {
File::deleteDirectory($chunkDir);
return response()->json(['error' => 'Invalid file type'], 422);
}
$assembledName = 'restore_' . uniqid() . '.' . $ext;
$assembledPath = "{$outputDir}/{$assembledName}";
$output = fopen($assembledPath, 'wb');
for ($i = 0; $i < $totalChunks; $i++) {
$chunkPath = "{$chunkDir}/chunk_{$i}";
$input = fopen($chunkPath, 'rb');
stream_copy_to_stream($input, $output);
fclose($input);
}
fclose($output);
// Clean up chunks
File::deleteDirectory($chunkDir);
// Validate ZIP if applicable
if ($ext === 'zip') {
$zip = new ZipArchive();
if ($zip->open($assembledPath) !== true) {
@unlink($assembledPath);
return response()->json(['error' => 'Invalid ZIP file'], 422);
}
if ($zip->getFromName('backup.json') === false) {
$zip->close();
@unlink($assembledPath);
return response()->json(['error' => 'ZIP does not contain backup.json'], 422);
}
$zip->close();
}
// Store path in session for Livewire to retrieve
session(["backup_restore_file_{$uploadId}" => $assembledPath]);
return response()->json([
'success' => true,
'uploadId' => $uploadId,
]);
}
/**
* Authorization check matching RequiresAdminAuthorization trait logic.
*/
private function authorizeAdminAccess(): void
{
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
if (!$activeProfileType || !$activeProfileId) {
abort(403, 'No active profile selected');
}
$profile = $activeProfileType::find($activeProfileId);
if (!$profile) {
abort(403, 'Active profile not found');
}
ProfileAuthorizationHelper::authorize($profile);
if ($profile instanceof \App\Models\Admin) {
return;
}
if ($profile instanceof \App\Models\Bank && $profile->level === 0) {
return;
}
abort(403, 'Admin or central bank access required');
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\ProfileController;
class BankController extends Controller
{
public function show($id)
{
return app(ProfileController::class)->showBank($id);
}
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit()
{
$bank = getActiveProfile();
// Verify this is a bank profile
if (!($bank instanceof \App\Models\Bank)) {
abort(403, 'Not a valid bank profile');
}
// Verify the user can manage this bank
$user = Auth::guard('web')->user();
if (!$user->banksManaged->contains($bank->id)) {
abort(403, 'You do not have permission to edit this bank');
}
return view('profile-bank.edit', [
'bank' => $bank
]);
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function settings()
{
return view('profile-bank.settings');
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Http\Controllers;
use App\Events\ProfileSwitchEvent;
use App\Models\Bank;
use App\Models\User;
use App\Traits\SwitchGuardTrait;
use Hash;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class BankLoginController extends Controller
{
use SwitchGuardTrait;
/**
* Direct link to bank login - can be used in emails
* Handles the layered authentication flow:
* 1. If user not authenticated -> redirect to user login first
* 2. If user authenticated -> set intent and redirect to bank login
* 3. After bank login -> redirect to intended URL or main page
*/
public function directLogin(Request $request, $bankId)
{
// Validate bank exists and load managers
$bank = Bank::with('managers')->find($bankId);
if (!$bank) {
abort(404, __('Bank not found'));
}
// Get optional intended destination after successful bank login
$intendedUrl = $request->query('intended');
// Store intended URL in session if provided
if ($intendedUrl) {
session(['bank_login_intended_url' => $intendedUrl]);
}
// Check if user is authenticated on web guard
if (!Auth::guard('web')->check()) {
// User not logged in - redirect to user login with return URL
$returnUrl = route('bank.direct-login', ['bankId' => $bankId]);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
// Store in session for Laravel to redirect after login
session()->put('url.intended', $returnUrl);
// Get the first manager's name to pre-populate the login form
$firstManager = $bank->managers()->first();
if ($firstManager) {
// Build the login URL with the name parameter and localization
$loginRoute = route('login') . '?name=' . urlencode($firstManager->name);
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
return redirect($loginUrl);
}
return redirect()->route('login');
}
// User is authenticated - verify they manage this bank
$user = Auth::guard('web')->user();
$userWithRelations = User::with('banksManaged')->find($user->id);
if (!$userWithRelations || !$userWithRelations->banksManaged->contains('id', $bankId)) {
abort(403, __('You do not have access to this bank'));
}
// Set the profile switch intent in session
session([
'intended_profile_switch_type' => 'Bank',
'intended_profile_switch_id' => $bankId,
]);
// Redirect to bank login form
return redirect()->route('bank.login');
}
public function showLoginForm()
{
$user = Auth::guard('web')->user();
$type = session('intended_profile_switch_type');
$id = session('intended_profile_switch_id');
$profile = $this->getTargetProfileByTypeAndId($user, $type, $id);
return view('profile-bank.login', ['profile' => $profile]);
}
public function login(Request $request)
{
$request->validate([
'password' => 'required',
]);
$user = Auth::guard('web')->user();
$type = session('intended_profile_switch_type');
$id = session('intended_profile_switch_id');
$bank = $this->getTargetProfileByTypeAndId($user, $type, $id);
if (!$bank) {
return back()->withErrors(['index' => __('Bank not found')]);
}
// Legacy Cyclos password support
if (!empty($bank->cyclos_salt)) {
info('Auth attempt using original Cyclos password');
$concatenated = $bank->cyclos_salt . $request->password;
$hashedInputPassword = hash("sha256", $concatenated);
if (strtolower($hashedInputPassword) === strtolower($bank->password)) {
info('Auth success: Password is verified');
// Rehash to Laravel hash and remove salt
$bank->password = \Hash::make($request->password);
$bank->cyclos_salt = null;
$bank->save();
info('Auth success: Cyclos password has been rehashed for next login');
}
}
// Check if the provided password matches the hashed password in the database
if (\Hash::check($request->password, $bank->password)) {
$this->switchGuard('bank', $bank); // Log in as bank
// Remove intended switch from session
session()->forget(['intended_profile_switch_type', 'intended_profile_switch_id']);
// Set active profile session as before
session([
'activeProfileType' => get_class($bank),
'activeProfileId' => $bank->id,
'activeProfileName' => $bank->name,
'activeProfilePhoto' => $bank->profile_photo_path,
'last_activity' => now(),
'profile-switched-notification' => true,
]);
// Re-activate profile if inactive
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$bank->isActive()) {
$bank->inactive_at = null;
$bank->save();
info('Bank re-activated: ' . $bank->name);
}
}
event(new \App\Events\ProfileSwitchEvent($bank));
// Check for intended URL from direct login flow
$intendedUrl = session('bank_login_intended_url');
if ($intendedUrl) {
session()->forget('bank_login_intended_url');
return redirect($intendedUrl);
}
return redirect()->route('main'); // TODO: Or special bank main page
}
info('Auth failed: Input password does not match stored password');
return back()->withErrors(['password' => __('Invalid bank password')]);
}
//TODO: Move to Trait as suggested below.
/**
* Helper method to get the target profile model based on the index.
* Note: This duplicates logic from ProfileSelect::mount. Consider refactoring
* this logic into a service or trait if used in multiple places.
*/
private function getTargetProfileByTypeAndId($user, $type, $id)
{
if (!$type || !$id) {
return null;
}
$userWithRelations = User::with(['organizations', 'banksManaged', 'admins'])->find($user->id);
if (!$userWithRelations) return null;
if (strtolower($type) === 'organization') {
return $userWithRelations->organizations->firstWhere('id', $id);
} elseif (strtolower($type) === 'bank') {
return $userWithRelations->banksManaged->firstWhere('id', $id);
} elseif (strtolower($type) === 'admin') {
return $userWithRelations->admins->firstWhere('id', $id);
}
return null;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Broadcasting\BroadcastController as BaseBroadcastController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Broadcast;
class BroadcastController extends BaseBroadcastController
{
/**
* Authenticate the request for channel access.
*
* This custom implementation supports multi-guard authentication.
* It uses the active guard from the session to authenticate broadcasting requests.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function authenticate(Request $request)
{
// Get the active guard from session (set by SwitchGuardTrait)
$activeGuard = session('active_guard', 'web');
// Ensure the user is authenticated on the active guard
if (!Auth::guard($activeGuard)->check()) {
abort(403, 'Unauthorized - No active authentication found');
}
// Get the authenticated user from the active guard
$user = Auth::guard($activeGuard)->user();
// Set the default guard to use for this request
Auth::shouldUse($activeGuard);
// Get the channel name from the request
$channelName = $request->input('channel_name');
// Attempt to authorize the channel
try {
$result = Broadcast::auth($request);
return response()->json($result);
} catch (\Exception $e) {
\Log::error('Broadcasting authentication failed', [
'guard' => $activeGuard,
'user_id' => $user->id,
'channel' => $channelName,
'error' => $e->getMessage()
]);
abort(403, 'Unauthorized');
}
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers;
use App\Models\Call;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
class CallController extends Controller
{
public function showById($id)
{
$call = Call::withTrashed()->with([
'callable',
'translations' => function ($query) {
$query->where('locale', App::getLocale());
},
'tag.contexts.category.translations',
'tag.contexts.category.ancestors.translations',
'location.city.translations',
'location.district.translations',
'location.country.translations',
'location.division.translations',
'loveReactant.reactionCounters',
])->findOrFail($id);
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
$isAdmin = $activeProfileType === \App\Models\Admin::class;
$isCallable = $activeProfileType === $call->callable_type
&& (int) $activeProfileId === (int) $call->callable_id;
// Deleted calls are only visible to admins and the callable itself
$isDeleted = $call->trashed();
if ($isDeleted && !$isAdmin && !$isCallable) {
return view('calls.unavailable');
}
// Blocked calls are only visible to admins and the callable itself
if ($call->is_suppressed && !$isAdmin && !$isCallable) {
return view('calls.unavailable');
}
// Paused calls are only visible to admins and the callable itself
if ($call->is_paused && !$isAdmin && !$isCallable) {
return view('calls.unavailable');
}
// Guests cannot view private calls
if (!$call->is_public && !Auth::check()) {
return redirect()->route('login')
->with('status', __('This call is private. Please log in to view it.'));
}
// Expired calls are only visible to the owner and admins
$isExpired = $call->till !== null && $call->till->isPast();
if ($isExpired && !$isCallable && !$isAdmin) {
return view('calls.unavailable');
}
$locale = App::getLocale();
// Build category hierarchy
$tagCategory = $call->tag?->contexts->first()?->category;
$tagColor = $tagCategory?->relatedColor ?? 'gray';
$tagCategories = [];
if ($tagCategory) {
foreach ($tagCategory->ancestorsAndSelf()->get()->reverse() as $cat) {
$name = $cat->translations->firstWhere('locale', $locale)?->name
?? $cat->translations->first()?->name
?? '';
if ($name) {
$tagCategories[] = ['name' => $name, 'color' => $cat->relatedColor ?? 'gray'];
}
}
}
// Build callable profile's location short string (city first, fallback to division or country)
$callableLocation = null;
if ($call->callable && method_exists($call->callable, 'locations')) {
$firstLoc = $call->callable->locations()->with(['city.translations', 'division.translations', 'country.translations'])->first();
if ($firstLoc) {
$cCity = optional($firstLoc->city?->translations->first())->name;
$cDivision = optional($firstLoc->division?->translations->first())->name;
$cCountry = optional($firstLoc->country?->translations->first())->name;
$callableLocation = $cCity ?? $cDivision ?? $cCountry ?: null;
}
}
// Build call exchange location: "City District, Division, COUNTRY_CODE"
$callLocation = null;
if ($call->location) {
$loc = $call->location;
$parts = [];
$cityPart = null;
if ($loc->city) {
$cityPart = optional($loc->city->translations->first())->name;
}
if ($loc->district) {
$districtName = optional($loc->district->translations->first())->name;
$cityPart = $cityPart ? $cityPart . ' ' . $districtName : $districtName;
}
if ($cityPart) $parts[] = $cityPart;
if ($loc->division) {
$divisionName = optional($loc->division->translations->first())->name;
if ($divisionName) $parts[] = $divisionName;
}
if ($loc->country && $loc->country->code !== 'XX') {
$parts[] = strtoupper($loc->country->code);
} elseif ($loc->country && $loc->country->code === 'XX') {
$parts[] = __('Location not specified');
}
$callLocation = $parts ? implode(', ', $parts) : null;
}
// Determine whether to show the expiry badge (within configured warning window)
$expiryWarningDays = timebank_config('calls.expiry_warning_days', 7);
$expiryBadgeText = null;
if ($call->till && $expiryWarningDays !== null) {
$daysLeft = (int) now()->startOfDay()->diffInDays($call->till->startOfDay(), false);
if ($daysLeft <= $expiryWarningDays) {
if ($daysLeft <= 0) {
$expiryBadgeText = __('Expires today');
} elseif ($daysLeft === 1) {
$expiryBadgeText = __('Expires tomorrow');
} else {
$expiryBadgeText = __('Expires in :days days', ['days' => $daysLeft]);
}
}
}
$callableType = $call->callable ? strtolower(class_basename($call->callable)) : null;
$viewName = Auth::check() ? 'calls.show' : 'calls.show-guest';
return view($viewName, compact('call', 'tagColor', 'tagCategories', 'callableType', 'callableLocation', 'callLocation', 'expiryBadgeText', 'isExpired', 'isDeleted', 'isAdmin'));
}
public function manage()
{
$activeProfileType = getActiveProfileType();
$allowedTypes = [
'User', 'Organization', 'Bank', 'Admin',
];
if (!$activeProfileType || !in_array($activeProfileType, $allowedTypes)) {
abort(403, __('You do not have access to this page.'));
}
return view('calls.manage');
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
public function manage()
{
return view('categories.manage');
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Organization;
use App\Models\Bank;
use App\Models\Admin;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ChatController extends Controller
{
/**
* Create a new conversation with a specific profile and redirect to the chat.
*
* @param string $profileType The type of profile (user, organization, bank, admin)
* @param int $id The ID of the profile
* @return \Illuminate\Http\RedirectResponse
*/
public function startConversationWith(string $profileType, int $id)
{
// Ensure user is authenticated
if (!Auth::guard('web')->check() &&
!Auth::guard('organization')->check() &&
!Auth::guard('bank')->check() &&
!Auth::guard('admin')->check()) {
abort(403, 'Unauthorized');
}
// Map profile type to model class
$modelClass = match(strtolower($profileType)) {
'user' => User::class,
'organization' => Organization::class,
'bank' => Bank::class,
'admin' => Admin::class,
default => abort(404, 'Invalid profile type'),
};
// Find the recipient profile
$recipient = $modelClass::find($id);
if (!$recipient) {
abort(404, 'Profile not found');
}
// Check if recipient is removed
if (method_exists($recipient, 'isRemoved') && $recipient->isRemoved()) {
abort(404, 'Profile no longer available');
}
// Get the active profile using the helper function
$activeProfile = getActiveProfile();
// Prevent creating conversation with yourself
if ($activeProfile->id === $recipient->id && get_class($activeProfile) === get_class($recipient)) {
return redirect()->route('chats')->with('error', __('You cannot start a conversation with yourself.'));
}
// Create or get existing conversation
$conversation = $activeProfile->createConversationWith($recipient);
// Redirect to the chat page
return redirect()->route('chat', ['conversation' => $conversation->id]);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Pipeline\Pipeline;
use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Http\Requests\LoginRequest;
class CustomAuthenticatedSessionController
{
public function store(LoginRequest $request): LoginResponse
{
return $this->loginPipeline($request)->then(function ($request) {
// Skip session regeneration in Docker environment to avoid session persistence issues
if (!env('IS_DOCKER', false)) {
$request->session()->regenerate();
}
return app(LoginResponse::class);
});
}
protected function loginPipeline(LoginRequest $request)
{
if (method_exists($this, 'fortify')) {
return $this->fortify()->loginPipeline($request);
}
return (new Pipeline(app()))->send($request)->through(array_filter([
config('fortify.limiters.login') ? null : \Laravel\Fortify\Actions\EnsureLoginIsNotThrottled::class,
config('fortify.lowercase_usernames') ? \Laravel\Fortify\Actions\CanonicalizeUsername::class : null,
\Laravel\Fortify\Actions\AttemptToAuthenticate::class,
// Skip PrepareAuthenticatedSession in Docker as it calls session()->migrate() which causes issues
env('IS_DOCKER', false) ? null : \Laravel\Fortify\Actions\PrepareAuthenticatedSession::class,
]));
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Services\PresenceService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Session;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
class CustomLogoutController extends Controller
{
public function destroy(Request $request)
{
$presenceService = app(PresenceService::class);
// Log out from all guards and set them offline
foreach (['web', 'admin', 'bank', 'organization'] as $guard) {
if (Auth::guard($guard)->check()) {
$user = Auth::guard($guard)->user();
// Set user offline before logging out
$presenceService->setUserOffline($user, $guard);
// Clear presence caches
Cache::forget("presence_{$guard}_{$user->id}");
Cache::forget("presence_last_update_{$guard}_{$user->id}");
// Logout
Auth::guard($guard)->logout();
}
}
$request->session()->invalidate();
$request->session()->regenerateToken();
// For AJAX/Livewire requests, force a hard redirect
if ($request->expectsJson() || $request->hasHeader('X-Livewire')) {
$redirectUrl = LaravelLocalization::localizeUrl('/');
return response('<script>window.location.href = "' . $redirectUrl . '";</script>', 200)
->header('Content-Type', 'text/html');
}
// Force a full page redirect with no-cache headers
$response = redirect(LaravelLocalization::localizeUrl('/'));
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
return $response;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Exports\TransactionsExport;
use App\Exports\UsersExport;
use Illuminate\Support\Facades\Session;
use Maatwebsite\Excel\Facades\Excel;
class ExportController extends Controller
{
public function allUsersExport($year = null)
{
return Excel::download(new UsersExport(), 'users.xlsx');
}
public function transactionsExport($type)
{
$dataArray = Session::get('export_data', []);
Session::forget('export_data'); // Optionally clear the session data after use
$data = collect($dataArray); // Convert array to collection
// Process the data and generate the export
return Excel::download(new TransactionsExport($data), "export.{$type}");
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\File;
class LangJsController extends Controller
{
/**
* Returns a JavaScript file containing all the translations for the supported locales in Laravel.
*
* @return \Illuminate\Http\Response
*/
public function js()
{
$locales = config('app.locales');
$translations = [];
foreach ($locales as $locale) {
$strings = File::get(resource_path("lang/{$locale}.json"));
// ds($strings)->label('LanJsController');
$translations[$locale] = json_decode($strings, true);
}
$js = "window.i18n = " . json_encode($translations) . ";";
$response = response($js, 200);
$response->header('Content-Type', 'application/javascript');
return $response;
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Http\Controllers;
use App\Models\EmailBounce;
use App\Models\Mailing;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
class MailgunWebhookController extends Controller
{
/**
* Handle Mailgun webhook events
*/
public function handle(Request $request)
{
// Verify webhook signature (recommended for production)
if (!$this->verifySignature($request)) {
Log::warning('Invalid Mailgun webhook signature');
return response('Unauthorized', 401);
}
$eventData = $request->input('event-data', []);
$event = $eventData['event'] ?? null;
Log::info('Mailgun webhook received', ['event' => $event, 'data' => $eventData]);
switch ($event) {
case 'bounced':
$this->handleBounce($eventData);
break;
case 'complained':
$this->handleComplaint($eventData);
break;
case 'unsubscribed':
$this->handleUnsubscribe($eventData);
break;
default:
Log::info('Unhandled Mailgun event', ['event' => $event]);
}
return response('OK', 200);
}
/**
* Handle bounce events
*/
protected function handleBounce(array $eventData)
{
$email = $eventData['recipient'] ?? null;
$errorCode = $eventData['delivery-status']['code'] ?? null;
$errorMessage = $eventData['delivery-status']['description'] ?? 'Unknown bounce';
if (!$email) {
Log::warning('Bounce event missing recipient email', $eventData);
return;
}
// Determine bounce type based on error code
$bounceType = $this->determineBounceTypeFromCode($errorCode);
// Extract mailing ID from message tags or headers if available
$mailingId = $this->extractMailingId($eventData);
EmailBounce::recordBounce($email, $bounceType, $errorMessage, $mailingId);
Log::info("Recorded {$bounceType} bounce", [
'email' => $email,
'code' => $errorCode,
'message' => $errorMessage,
'mailing_id' => $mailingId,
]);
}
/**
* Handle complaint (spam) events
*/
protected function handleComplaint(array $eventData)
{
$email = $eventData['recipient'] ?? null;
if (!$email) {
Log::warning('Complaint event missing recipient email', $eventData);
return;
}
$mailingId = $this->extractMailingId($eventData);
EmailBounce::recordBounce($email, 'complaint', 'Spam complaint', $mailingId);
Log::warning("Recorded spam complaint", [
'email' => $email,
'mailing_id' => $mailingId,
]);
}
/**
* Handle unsubscribe events
*/
protected function handleUnsubscribe(array $eventData)
{
$email = $eventData['recipient'] ?? null;
if (!$email) {
Log::warning('Unsubscribe event missing recipient email', $eventData);
return;
}
// You might want to handle unsubscribes differently
// For now, we'll just log it
Log::info("Unsubscribe event", ['email' => $email]);
// Optionally suppress the email
// EmailBounce::suppressEmail($email, 'Unsubscribed via Mailgun');
}
/**
* Determine bounce type from Mailgun error code
*/
protected function determineBounceTypeFromCode($code): string
{
if (!$code) return 'unknown';
// Mailgun error codes for hard bounces
$hardBounceCodes = [550, 551, 553, 554];
// Mailgun error codes for soft bounces
$softBounceCodes = [421, 450, 451, 452, 552];
if (in_array($code, $hardBounceCodes)) {
return 'hard';
}
if (in_array($code, $softBounceCodes)) {
return 'soft';
}
return 'unknown';
}
/**
* Extract mailing ID from event data
*/
protected function extractMailingId(array $eventData): ?int
{
// Check if mailing ID is in message tags
$tags = $eventData['message']['headers']['message-id'] ?? '';
if (preg_match('/mailing-(\d+)/', $tags, $matches)) {
return (int) $matches[1];
}
// Check custom headers
$headers = $eventData['message']['headers'] ?? [];
if (isset($headers['X-Mailing-ID'])) {
return (int) $headers['X-Mailing-ID'];
}
return null;
}
/**
* Verify Mailgun webhook signature
*/
protected function verifySignature(Request $request): bool
{
$timestamp = $request->input('timestamp');
$token = $request->input('token');
$signature = $request->input('signature');
$signingKey = config('services.mailgun.webhook_key');
if (!$signingKey) {
Log::warning('Mailgun webhook signing key not configured');
return config('app.env') !== 'production'; // Skip verification in non-production
}
$expectedSignature = hash_hmac('sha256', $timestamp . $token, $signingKey);
return hash_equals($expectedSignature, $signature);
}
}

View File

@@ -0,0 +1,262 @@
<?php
namespace App\Http\Controllers;
use App\Models\Mailing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
class MailingsController extends Controller
{
/**
* Display the mailings management page
*/
public function index()
{
// Check authorization - only admins and banks can manage mailings
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to access mailings management.');
}
return view('mailings.manage');
}
/**
* Store a new mailing
*/
public function store(Request $request)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to create mailings.');
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'type' => ['required', Rule::in(['local_newsletter', 'general_newsletter', 'system_message'])],
'subject' => 'required|string|max:255',
'content_blocks' => 'nullable|array',
'content_blocks.*.post_id' => 'required|integer|exists:posts,id',
'content_blocks.*.order' => 'required|integer|min:1',
'scheduled_at' => 'nullable|date|after:now',
]);
// Determine creator
$creator = Auth::guard('admin')->user() ?: Auth::guard('bank')->user();
$mailing = Mailing::create([
'title' => $validated['title'],
'type' => $validated['type'],
'subject' => $validated['subject'],
'content_blocks' => $validated['content_blocks'] ?? [],
'scheduled_at' => $validated['scheduled_at'] ?? null,
'status' => $validated['scheduled_at'] ? 'scheduled' : 'draft',
'created_by_id' => $creator->id,
'created_by_type' => get_class($creator),
]);
// Update recipient count
$mailing->recipients_count = $mailing->getRecipientsQuery()->count();
$mailing->save();
return response()->json([
'success' => true,
'message' => 'Mailing created successfully.',
'mailing' => $mailing->load('createdBy')
]);
}
/**
* Update an existing mailing
*/
public function update(Request $request, Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to update mailings.');
}
// Can only edit drafts
if (!$mailing->canBeScheduled()) {
return response()->json(['error' => 'Only draft mailings can be edited.'], 422);
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'type' => ['required', Rule::in(['local_newsletter', 'general_newsletter', 'system_message'])],
'subject' => 'required|string|max:255',
'content_blocks' => 'nullable|array',
'content_blocks.*.post_id' => 'required|integer|exists:posts,id',
'content_blocks.*.order' => 'required|integer|min:1',
'scheduled_at' => 'nullable|date|after:now',
]);
$mailing->update([
'title' => $validated['title'],
'type' => $validated['type'],
'subject' => $validated['subject'],
'content_blocks' => $validated['content_blocks'] ?? [],
'scheduled_at' => $validated['scheduled_at'] ?? null,
'status' => $validated['scheduled_at'] ? 'scheduled' : 'draft',
]);
// Update recipient count
$mailing->recipients_count = $mailing->getRecipientsQuery()->count();
$mailing->save();
return response()->json([
'success' => true,
'message' => 'Mailing updated successfully.',
'mailing' => $mailing->load('createdBy')
]);
}
/**
* Delete a mailing (soft delete)
*/
public function destroy(Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to delete mailings.');
}
// Can only delete drafts and scheduled mailings
if (!in_array($mailing->status, ['draft', 'scheduled'])) {
return response()->json(['error' => 'Cannot delete sent or sending mailings.'], 422);
}
$mailing->delete();
return response()->json([
'success' => true,
'message' => 'Mailing deleted successfully.'
]);
}
/**
* Send a mailing immediately
*/
public function send(Request $request, Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to send mailings.');
}
if (!$mailing->canBeSent()) {
return response()->json(['error' => 'Mailing cannot be sent in its current status.'], 422);
}
// Update status to sending
$mailing->update(['status' => 'sending']);
// Dispatch bulk email job (to be implemented in Phase 6)
// SendBulkMailJob::dispatch($mailing);
return response()->json([
'success' => true,
'message' => 'Mailing is being sent. This process may take several minutes.',
'mailing' => $mailing
]);
}
/**
* Schedule a mailing for future sending
*/
public function schedule(Request $request, Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to schedule mailings.');
}
if (!$mailing->canBeScheduled()) {
return response()->json(['error' => 'Mailing cannot be scheduled in its current status.'], 422);
}
$validated = $request->validate([
'scheduled_at' => 'required|date|after:now',
]);
$mailing->update([
'scheduled_at' => $validated['scheduled_at'],
'status' => 'scheduled'
]);
return response()->json([
'success' => true,
'message' => 'Mailing scheduled successfully.',
'mailing' => $mailing
]);
}
/**
* Cancel a scheduled mailing
*/
public function cancel(Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to cancel mailings.');
}
if (!$mailing->canBeCancelled()) {
return response()->json(['error' => 'Mailing cannot be cancelled in its current status.'], 422);
}
$mailing->update([
'scheduled_at' => null,
'status' => 'draft'
]);
return response()->json([
'success' => true,
'message' => 'Scheduled mailing cancelled and reverted to draft.',
'mailing' => $mailing
]);
}
/**
* Preview a mailing
*/
public function preview(Request $request, Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to preview mailings.');
}
// Get current user as preview recipient
$recipient = Auth::guard('admin')->user() ?: Auth::guard('bank')->user();
// Generate preview using NewsletterMail
$newsletterMail = new \App\Mail\NewsletterMail($mailing, $recipient);
return response()->json([
'success' => true,
'preview_url' => route('mailings.preview_render', $mailing->id)
]);
}
/**
* Render mailing preview as HTML
*/
public function previewRender(Mailing $mailing)
{
// Authorization check
if (!Auth::guard('admin')->check() && !Auth::guard('bank')->check()) {
abort(403, 'Unauthorized to preview mailings.');
}
// Get current user as preview recipient
$recipient = Auth::guard('admin')->user() ?: Auth::guard('bank')->user();
// Generate and render preview
$newsletterMail = new \App\Mail\NewsletterMail($mailing, $recipient);
return $newsletterMail->render();
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Models\MessageSetting;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class NewsletterUnsubscribeController extends Controller
{
/**
* Handle newsletter unsubscribe requests
*/
public function unsubscribe(Request $request)
{
$email = $request->get('email');
$type = $request->get('type');
$signature = $request->get('signature');
// Verify the signature to prevent unauthorized unsubscribes
$expectedSignature = hash_hmac('sha256', $email . $type, config('app.key'));
if (!hash_equals($expectedSignature, $signature)) {
return view('newsletter.unsubscribe-error', [
'message' => 'Invalid unsubscribe link. Please contact support if you need help unsubscribing.'
]);
}
// Validate newsletter type
if (!in_array($type, ['local_newsletter', 'general_newsletter', 'system_message'])) {
return view('newsletter.unsubscribe-error', [
'message' => 'Invalid newsletter type.'
]);
}
// Find the user or organization by email
$recipient = $this->findRecipientByEmail($email);
if (!$recipient) {
return view('newsletter.unsubscribe-error', [
'message' => 'Email address not found in our system.'
]);
}
// Get or create message settings
$messageSettings = $recipient->messageSettings()->first();
if (!$messageSettings) {
$messageSettings = new MessageSetting();
$messageSettings->message_settingable_id = $recipient->id;
$messageSettings->message_settingable_type = get_class($recipient);
// Set all newsletter types to true by default (assuming they were subscribed)
$messageSettings->local_newsletter = true;
$messageSettings->general_newsletter = true;
$messageSettings->system_message = true;
}
// Unsubscribe from the specific newsletter type
$messageSettings->{$type} = false;
$messageSettings->save();
return view('newsletter.unsubscribe-success', [
'email' => $email,
'type' => $type,
'typeName' => $this->getNewsletterTypeName($type),
'recipient' => $recipient
]);
}
/**
* Find recipient (User or Organization) by email
*/
protected function findRecipientByEmail(string $email)
{
// Try to find a User first
$user = User::where('email', $email)->first();
if ($user) {
return $user;
}
// Try to find an Organization
$organization = Organization::where('email', $email)->first();
if ($organization) {
return $organization;
}
return null;
}
/**
* Get human-readable newsletter type name
*/
protected function getNewsletterTypeName(string $type): string
{
return match ($type) {
'local_newsletter' => 'Local Newsletter',
'general_newsletter' => 'General Newsletter',
'system_message' => 'System Messages',
default => 'Newsletter'
};
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Organization;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class OrganizationController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
//
}
public function show($orgId)
{
$org = Organization::select([
'id',
'name',
'profile_photo_path',
'about',
'motivation',
'website',
// 'phone_public',
'created_at',
// 'last_login_at',
'love_reactant_id',
'inactive_at',
])
->with([
'users:id,name,profile_photo_path',
'accounts:id,name,accountable_type,accountable_id',
'languages:id,name,lang_code,flag',
'socials:id,name,icon,urL_structure',
'locations.district.translations:district_id,name',
'locations.city.translations:city_id,name',
'locations.division.translations:division_id,name',
'locations.country.translations:country_id,name',
'loveReactant.reactions.reacter.reacterable',
// 'loveReactant.reactions.type',
'loveReactant.reactionCounters',
// 'loveReactant.reactionTotal',
])
->find($orgId);
if ($org->count() >= 1) {
$org->reactionCounter = $org->loveReactant->reactionCounters->first() ? (int)$org->loveReactant->reactionCounters->first()->weight : null;
$registerDate = Carbon::createFromTimeStamp(strtotime($org->created_at))->isoFormat('LL');
$lastLoginDate = Carbon::createFromTimeStamp(strtotime($org->last_login_at))->isoFormat('LL');
} else {
return view('profile-organization.not_found');
}
//TODO: add permission check
//TODO: if 403, but has permission, redirect with message to switch profile
//TODO: replace 403 with custom redirect page incl explanation
return ($org != null ? view('profile-organization.show', compact(['org'])) : abort(403));
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit()
{
return view('profile-organization.edit');
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy($id)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function settings()
{
return view('profile-organization.settings');
}
}

View File

@@ -0,0 +1,221 @@
<?php
namespace App\Http\Controllers;
use App\Events\ProfileSwitchEvent;
use App\Models\Organization;
use App\Models\User;
use App\Traits\SwitchGuardTrait;
use Hash;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class OrganizationLoginController extends Controller
{
use SwitchGuardTrait;
/**
* Direct link to organization profile - can be used in emails
* Organizations don't require password re-authentication
* Handles the authentication flow:
* 1. If user not authenticated -> redirect to user login first
* 2. If user authenticated -> verify access and switch to organization
* 3. Redirect to intended URL or main page
*/
public function directLogin(Request $request, $organizationId)
{
// Validate organization exists and load managers
$organization = Organization::with('managers')->find($organizationId);
if (!$organization) {
abort(404, __('Organization not found'));
}
// Get optional intended destination after successful org switch
$intendedUrl = $request->query('intended');
// Check if user is authenticated on web guard
if (!Auth::guard('web')->check()) {
// User not logged in - redirect to user login with return URL
$returnUrl = route('organization.direct-login', ['organizationId' => $organizationId]);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
// Store in session for Laravel to redirect after login
session()->put('url.intended', $returnUrl);
// Get the first manager's name to pre-populate the login form
$firstManager = $organization->managers()->first();
\Log::info('OrganizationLoginController: Redirecting unauthenticated user', [
'organization_id' => $organizationId,
'manager_found' => $firstManager ? 'yes' : 'no',
'manager_name' => $firstManager ? $firstManager->name : null,
]);
if ($firstManager) {
// Build the login URL with the name parameter and localization
$loginRoute = route('login') . '?name=' . urlencode($firstManager->name);
$loginUrl = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getLocalizedURL(null, $loginRoute);
\Log::info('OrganizationLoginController: Redirect URL', [
'url' => $loginUrl,
]);
return redirect($loginUrl);
}
return redirect()->route('login');
}
// User is authenticated - verify they own/manage this organization
$user = Auth::guard('web')->user();
$userWithRelations = User::with('organizations')->find($user->id);
if (!$userWithRelations || !$userWithRelations->organizations->contains('id', $organizationId)) {
abort(403, __('You do not have access to this organization'));
}
// Switch to organization guard directly (no password required for organizations)
$this->switchGuard('organization', $organization);
// Set active profile session
session([
'activeProfileType' => get_class($organization),
'activeProfileId' => $organization->id,
'activeProfileName' => $organization->name,
'activeProfilePhoto' => $organization->profile_photo_path,
'last_activity' => now(),
'profile-switched-notification' => true,
]);
// Re-activate profile if inactive
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$organization->isActive()) {
$organization->inactive_at = null;
$organization->save();
info('Organization re-activated: ' . $organization->name);
}
}
// Broadcast profile switch event
event(new \App\Events\ProfileSwitchEvent($organization));
// Redirect to intended URL or main page
if ($intendedUrl) {
return redirect($intendedUrl);
}
return redirect()->route('main');
}
public function showLoginForm()
{
$user = Auth::guard('web')->user();
$type = session('intended_profile_switch_type');
$id = session('intended_profile_switch_id');
$profile = $this->getTargetProfileByTypeAndId($user, $type, $id);
return view('profile-organization.login', ['profile' => $profile]);
}
public function login(Request $request)
{
$request->validate([
'password' => 'required',
]);
$user = Auth::guard('web')->user();
$type = session('intended_profile_switch_type');
$id = session('intended_profile_switch_id');
$organization = $this->getTargetProfileByTypeAndId($user, $type, $id);
if (!$organization) {
return back()->withErrors(['index' => __('Organization not found')]);
}
// Legacy Cyclos password support
if (!empty($organization->cyclos_salt)) {
info('Auth attempt using original Cyclos password');
$concatenated = $organization->cyclos_salt . $request->password;
$hashedInputPassword = hash("sha256", $concatenated);
if (strtolower($hashedInputPassword) === strtolower($organization->password)) {
info('Auth success: Password is verified');
// Rehash to Laravel hash and remove salt
$organization->password = \Hash::make($request->password);
$organization->cyclos_salt = null;
$organization->save();
info('Auth success: Cyclos password has been rehashed for next login');
}
}
// Check if the provided password matches the hashed password in the database
if (\Hash::check($request->password, $organization->password)) {
$this->switchGuard('organization', $organization); // log in as organization
// Remove intended switch from session
session()->forget(['intended_profile_switch_type', 'intended_profile_switch_id']);
// Set active profile session as before
session([
'activeProfileType' => get_class($organization),
'activeProfileId' => $organization->id,
'activeProfileName' => $organization->name,
'activeProfilePhoto' => $organization->profile_photo_path,
'last_activity' => now(),
'profile-switched-notification' => true,
]);
// Re-activate profile if inactive
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$organization->isActive()) {
$organization->inactive_at = null;
$organization->save();
info('Organization re-activated: ' . $organization->name);
}
}
event(new \App\Events\ProfileSwitchEvent($organization));
// Check for intended URL from direct login flow
$intendedUrl = session('organization_login_intended_url');
if ($intendedUrl) {
session()->forget('organization_login_intended_url');
return redirect($intendedUrl);
}
return redirect()->route('main'); // Or your special organization main page
}
info('Auth failed: Input password does not match stored password');
return back()->withErrors(['password' => __('Invalid organization password')]);
}
//TODO: Move to Trait as suggested below.
/**
* Helper method to get the target profile model based on the index.
* Note: This duplicates logic from ProfileSelect::mount. Consider refactoring
* this logic into a service or trait if used in multiple places.
*/
private function getTargetProfileByTypeAndId($user, $type, $id)
{
if (!$type || !$id) {
return null;
}
$userWithRelations = User::with(['organizations', 'banksManaged', 'admins'])->find($user->id);
if (!$userWithRelations) return null;
if (strtolower($type) === 'organization') {
return $userWithRelations->organizations->firstWhere('id', $id);
} elseif (strtolower($type) === 'bank') {
return $userWithRelations->banksManaged->firstWhere('id', $id);
} elseif (strtolower($type) === 'admin') {
return $userWithRelations->admins->firstWhere('id', $id);
}
return null;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PermissionController extends Controller
{
public function manage()
{
return view('permissions.manage');
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers;
use App\Models\Language;
use App\Models\Post;
use App\Models\PostTranslation;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
class PostController extends Controller
{
public function showById($id)
{
$post =
Post::with([
'postable' => function ($query) {
$query->select(['id', 'name']);
},
'category' => function ($query) {
$query->with('translations');
},
'translations' => function ($query) {
$query
->where('locale', App::getLocale());
// ->whereDate('from', '<=', now()) //TODO: Exclude date queries ONLY for post Admins!
// ->where( function($query) {
// $query->whereDate('till', '>', now())->orWhereNull('till');
// })
},
])
->where('id', $id)
->firstOrFail();
if ($post->media) {
$media = Post::find($id)->getFirstMedia('posts');
}
if ($post->translations->count() >= 1) {
if ($post->category->translations) {
$category = $post->category->translations->where('locale', App::getLocale())->first()->name;
}
$update = Carbon::createFromTimeStamp(strtotime($post->translations->first()->updated_at))->isoFormat('LL');
} else {
// No translation in current locale - check for fallback translations
return $this->handleNoTranslation($post, $id);
}
// Use guest layout for non-authenticated users, app layout for authenticated users
$viewName = Auth::check() ? 'posts.show' : 'posts.show-guest';
return ($post != null ? view($viewName, compact(['post','media','category','update'])) : abort(403));
}
/**
* Handle case when no translation exists for current locale.
* Check for available translations in other locales and offer fallback.
*/
private function handleNoTranslation($post, $postId)
{
// Get all available translations for this post (active ones)
$availableTranslation = PostTranslation::where('post_id', $postId)
->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
})
->first();
$viewData = [
'fallbackLocale' => null,
'fallbackLanguageName' => null,
'fallbackUrl' => null,
'post' => $post,
];
if ($availableTranslation) {
// Found an available translation in another locale
$fallbackLocale = $availableTranslation->locale;
$fallbackLanguageName = Language::where('lang_code', $fallbackLocale)->first()->name ?? $fallbackLocale;
// Generate URL to the same post in the fallback locale
$fallbackUrl = \LaravelLocalization::getLocalizedURL($fallbackLocale, route('post.show', ['id' => $postId]));
$viewData['fallbackLocale'] = $fallbackLocale;
$viewData['fallbackLanguageName'] = __($fallbackLanguageName);
$viewData['fallbackUrl'] = $fallbackUrl;
}
// Use guest layout for non-authenticated users, app layout for authenticated users
$viewName = Auth::check() ? 'posts.no_translation' : 'posts.no_translation-guest';
return view($viewName, $viewData);
}
/**
* Show view by .../posts/{slug}.
* Post will be shown in user's App:locale() language if available, even is the slug is in another language.
*
* @param mixed $slug
* @return void
*/
public function showBySlug($slug)
{
$postTranslation = PostTranslation::where('slug', $slug)->get()->first();
if (!$postTranslation) {
return view('posts.not_found');
}
$id = $postTranslation->post_id;
$locale = $postTranslation->locale;
$post = Post::with([
'postable' => function ($query) {
$query->select(['id', 'name']);
},
'category' => function ($query) {
$query->with('translations');
},
'meeting',
'translations' => function ($query) {
//TODO!: Currently only user 1 (Super-admin) can view unpublished posts, change to permission/role based!
$query->where('locale', App::getLocale());
// Only show published posts for guests and non-admin users
if (!Auth::guard('web')->check() || Auth::guard('web')->user()->id != 1) {
$query->whereDate('from', '<=', now())
->where(function ($query) {
$query->whereDate('till', '>', now())->orWhereNull('till');
});
}
}
])
->where('id', $id)
->first();
if ($post->media) {
$media = Post::find($id)->getFirstMedia('posts');
}
if ($post->translations->count() >= 1) {
if ($post->category->translations) {
$category = $post->category->translations->where('locale', App::getLocale())->first()->name;
}
$update = Carbon::createFromTimeStamp(strtotime($post->translations->first()->updated_at))->isoFormat('LL');
} else {
// No translation in current locale - check for fallback translations
return $this->handleNoTranslation($post, $id);
}
// Use guest layout for non-authenticated users, app layout for authenticated users
$viewName = Auth::check() ? 'posts.show' : 'posts.show-guest';
return ($post != null ? view($viewName, compact(['post','media','category','update'])) : abort(403));
}
public function manage()
{
if (getActiveProfileType() !== 'Admin') {
abort(403, __('Admin profile required'));
}
return view('posts.manage');
}
public function notFound()
{
return view('posts.not_found');
}
}

View File

@@ -0,0 +1,64 @@
<?php
// 2. Presence Controller for AJAX calls
namespace App\Http\Controllers;
use App\Services\PresenceService;
use Illuminate\Http\Request;
class PresenceController extends Controller
{
protected $presenceService;
public function __construct(PresenceService $presenceService)
{
$this->presenceService = $presenceService;
}
public function heartbeat(Request $request)
{
$guard = $request->get('guard', 'web');
if (auth($guard)->check()) {
$this->presenceService->updatePresence(auth($guard)->user(), $guard);
return response()->json([
'status' => 'online',
'timestamp' => now(),
'user_id' => auth($guard)->id()
]);
}
return response()->json(['status' => 'unauthorized'], 401);
}
public function setOffline(Request $request)
{
$guard = $request->get('guard', 'web');
if (auth($guard)->check()) {
$this->presenceService->setUserOffline(auth($guard)->user(), $guard);
return response()->json([
'status' => 'offline',
'timestamp' => now(),
'user_id' => auth($guard)->id()
]);
}
return response()->json(['status' => 'unauthorized'], 401);
}
public function getOnlineUsers(Request $request)
{
$guard = $request->get('guard', 'web');
$users = $this->presenceService->getOnlineUsers($guard);
return response()->json([
'users' => $users,
'count' => $users->count(),
'guard' => $guard,
'updated_at' => now()
]);
}
}

View File

@@ -0,0 +1,525 @@
<?php
namespace App\Http\Controllers;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use App\Traits\ActiveStatesTrait;
use App\Traits\LocationTrait;
use App\Traits\ProfilePermissionTrait;
use Illuminate\Http\Request;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
class ProfileController extends Controller
{
use ActiveStatesTrait;
use LocationTrait;
use ProfilePermissionTrait;
public function show($type, $id)
{
if (strtolower($type) === strtolower(__('user'))) {
return $this->showUser($id);
}
if (strtolower($type) === strtolower(__('organization'))) {
return $this->showOrganization($id);
}
if (strtolower($type) === strtolower(__('bank'))) {
return $this->showBank($id);
}
return view('profile.not_found');
}
public function showActive()
{
$profile = getActiveProfile();
return $this->show(__(class_basename($profile)), $profile->id);
}
public function showUser($id)
{
$user = User::select([
'id',
'name',
'full_name',
'profile_photo_path',
'about',
'about_short',
'motivation',
'date_of_birth',
'website',
'phone_public',
'phone',
'cyclos_skills',
'lang_preference',
'email_verified_at',
'created_at',
'last_login_at',
'love_reactant_id',
'inactive_at',
'deleted_at'
])
->with([
'organizations:id,name,profile_photo_path',
'accounts:id,name,accountable_type,accountable_id',
'languages:id,name,lang_code,flag',
'tags:tag_id',
'socials:id,name,icon,urL_structure',
'locations.district.translations:district_id,name',
'locations.city.translations:city_id,name',
'locations.division.translations:division_id,name',
'locations.country.translations:country_id,name',
'loveReactant.reactions.reacter.reacterable',
'loveReactant.reactionCounters',
])
->find($id);
if (!$user) {
return view('profile.not_found');
}
$states = $this->getActiveStates($user);
$canManageProfiles = $this->getCanManageProfiles();
$canViewIncompleteProfiles = $this->canViewIncompleteProfiles();
$isViewingOwnProfile = getActiveProfile() &&
get_class(getActiveProfile()) === User::class &&
getActiveProfile()->id === $user->id;
// For admins/banks: Always show incomplete label if profile is incomplete (ignore config)
if ($canManageProfiles) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$user->isActive();
$states['inactiveSince'] = $user->inactive_at
? \Illuminate\Support\Carbon::parse($user->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$user->isEmailVerified();
$states['incompleteLabel'] = $user->hasIncompleteProfile($user);
}
// When viewing own profile, never hide but show all labels
if ($isViewingOwnProfile) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$user->isActive();
$states['inactiveSince'] = $user->inactive_at
? \Illuminate\Support\Carbon::parse($user->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$user->isEmailVerified();
$states['incompleteLabel'] = $user->hasIncompleteProfile($user);
// Show incomplete warning modal when viewing own profile
$states['showIncompleteWarning'] = timebank_config('profile_incomplete.show_warning_modal') &&
$user->hasIncompleteProfile($user);
}
// Check if profile should be hidden
if ($states['hidden'] && !$canManageProfiles && !$isViewingOwnProfile) {
// Only allow Admin and Bank profiles to view incomplete-only profiles
// Inactive profiles should be hidden from everyone except admins/banks
$isIncompleteOnly = ($states['isIncomplete'] ?? false) &&
!($states['inactive'] ?? false) &&
!($states['emailUnverifiedLabel'] ?? false);
// Show profile only if it's incomplete-only AND viewer is admin/bank
if (!($isIncompleteOnly && $canViewIncompleteProfiles)) {
return view('profile.not_found');
}
}
$user->reactionCounter = $user->loveReactant->reactionCounters->first() ? (int)$user->loveReactant->reactionCounters->first()->weight : null;
$profile = $user;
$header = __('Personal profile');
return $profile != null
? view('profile.show', array_merge(compact('profile', 'header'), $states))
: abort(403);
}
public function showOrganization($id)
{
$organization = Organization::select([
'id',
'name',
'profile_photo_path',
'about',
'motivation',
'website',
'phone_public',
'phone',
'cyclos_skills',
'lang_preference',
'email_verified_at',
'created_at',
'last_login_at',
'love_reactant_id',
'inactive_at',
'deleted_at'
])
->with([
'managers:id,name,profile_photo_path',
'accounts:id,name,accountable_type,accountable_id',
'languages:id,name,lang_code,flag',
'tags:tag_id',
'socials:id,name,icon,urL_structure',
'locations.district.translations:district_id,name',
'locations.city.translations:city_id,name',
'locations.division.translations:division_id,name',
'locations.country.translations:country_id,name',
'loveReactant.reactions.reacter.reacterable',
// 'loveReactant.reactions.type',
'loveReactant.reactionCounters',
// 'loveReactant.reactionTotal',
])
->find($id);
if (!$organization) {
return view('profile.not_found');
}
$states = $this->getActiveStates($organization);
$canManageProfiles = $this->getCanManageProfiles();
$canViewIncompleteProfiles = $this->canViewIncompleteProfiles();
$isViewingOwnProfile = getActiveProfile() &&
get_class(getActiveProfile()) === Organization::class &&
getActiveProfile()->id === $organization->id;
// For admins/banks: Always show incomplete label if profile is incomplete (ignore config)
if ($canManageProfiles) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$organization->isActive();
$states['inactiveSince'] = $organization->inactive_at
? \Illuminate\Support\Carbon::parse($organization->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$organization->isEmailVerified();
$states['incompleteLabel'] = $organization->hasIncompleteProfile($organization);
}
// When viewing own profile, never hide but show all labels
if ($isViewingOwnProfile) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$organization->isActive();
$states['inactiveSince'] = $organization->inactive_at
? \Illuminate\Support\Carbon::parse($organization->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$organization->isEmailVerified();
$states['incompleteLabel'] = $organization->hasIncompleteProfile($organization);
// Show incomplete warning modal when viewing own profile
$states['showIncompleteWarning'] = timebank_config('profile_incomplete.show_warning_modal') &&
$organization->hasIncompleteProfile($organization);
}
// Check if profile should be hidden
if ($states['hidden'] && !$canManageProfiles && !$isViewingOwnProfile) {
// Only allow Admin and Bank profiles to view incomplete-only profiles
// Inactive profiles should be hidden from everyone except admins/banks
$isIncompleteOnly = ($states['isIncomplete'] ?? false) &&
!($states['inactive'] ?? false) &&
!($states['emailUnverifiedLabel'] ?? false);
// Show profile only if it's incomplete-only AND viewer is admin/bank
if (!($isIncompleteOnly && $canViewIncompleteProfiles)) {
return view('profile.not_found');
}
}
$organization->reactionCounter = $organization->loveReactant->reactionCounters->first() ? (int)$organization->loveReactant->reactionCounters->first()->weight : null;
$profile = $organization;
$header = __('Organization profile');
return $profile != null
? view('profile.show', array_merge(compact('profile', 'header'), $states))
: abort(403);
}
public function showBank($id)
{
$bank = Bank::select([
'id',
'name',
'profile_photo_path',
'about',
'motivation',
'website',
'phone_public',
'phone',
'cyclos_skills',
'lang_preference',
'email_verified_at',
'created_at',
'last_login_at',
'love_reactant_id',
'inactive_at',
'deleted_at'
])
->with([
'managers:id,name,profile_photo_path',
'accounts:id,name,accountable_type,accountable_id',
'languages:id,name,lang_code,flag',
'tags:tag_id',
'socials:id,name,icon,urL_structure',
'locations.district.translations:district_id,name',
'locations.city.translations:city_id,name',
'locations.division.translations:division_id,name',
'locations.country.translations:country_id,name',
'loveReactant.reactions.reacter.reacterable',
// 'loveReactant.reactions.type',
'loveReactant.reactionCounters',
// 'loveReactant.reactionTotal',
])
->find($id);
if (!$bank) {
return view('profile.not_found');
}
$states = $this->getActiveStates($bank);
$canManageProfiles = $this->getCanManageProfiles();
$canViewIncompleteProfiles = $this->canViewIncompleteProfiles();
$isViewingOwnProfile = getActiveProfile() &&
get_class(getActiveProfile()) === Bank::class &&
getActiveProfile()->id === $bank->id;
// For admins/banks: Always show incomplete label if profile is incomplete (ignore config)
if ($canManageProfiles) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$bank->isActive();
$states['inactiveSince'] = $bank->inactive_at
? \Illuminate\Support\Carbon::parse($bank->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$bank->isEmailVerified();
$states['incompleteLabel'] = $bank->hasIncompleteProfile($bank);
}
// When viewing own profile, never hide but show all labels
if ($isViewingOwnProfile) {
$states['hidden'] = false;
$states['inactiveLabel'] = !$bank->isActive();
$states['inactiveSince'] = $bank->inactive_at
? \Illuminate\Support\Carbon::parse($bank->inactive_at)->diffForHumans()
: '';
$states['emailUnverifiedLabel'] = !$bank->isEmailVerified();
$states['incompleteLabel'] = $bank->hasIncompleteProfile($bank);
// Show incomplete warning modal when viewing own profile
$states['showIncompleteWarning'] = timebank_config('profile_incomplete.show_warning_modal') &&
$bank->hasIncompleteProfile($bank);
}
// Check if profile should be hidden
if ($states['hidden'] && !$canManageProfiles && !$isViewingOwnProfile) {
// Only allow Admin and Bank profiles to view incomplete-only profiles
// Inactive profiles should be hidden from everyone except admins/banks
$isIncompleteOnly = ($states['isIncomplete'] ?? false) &&
!($states['inactive'] ?? false) &&
!($states['emailUnverifiedLabel'] ?? false);
// Show profile only if it's incomplete-only AND viewer is admin/bank
if (!($isIncompleteOnly && $canViewIncompleteProfiles)) {
return view('profile.not_found');
}
}
$bank->reactionCounter = $bank->loveReactant->reactionCounters->first() ? (int)$bank->loveReactant->reactionCounters->first()->weight : null;
$profile = $bank;
$header = __('Bank profile');
return $profile != null
? view('profile.show', array_merge(compact('profile', 'header'), $states))
: abort(403);
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit()
{
$type = strtolower(class_basename(getActiveProfile()));
$profile = getActiveProfile();
// Check if profile is incomplete - show warning if config allows
$showIncompleteWarning = false;
if (timebank_config('profile_incomplete.show_warning_modal') &&
method_exists($profile, 'hasIncompleteProfile') &&
$profile->hasIncompleteProfile($profile)) {
$showIncompleteWarning = true;
}
return view('profile-' . $type . '.edit', compact('showIncompleteWarning'));
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy($id)
{
//
}
/**
* Redirects the authenticated user to their profile settings page with locale-specific routing.
*
* This method is used by the route profile.settings.no_locale.
* The email footer template uses this route because when sending the locale of the
* recipient is unknown.
*
* @return \Illuminate\Http\RedirectResponse Redirects to the localized profile settings page.
*/
public function settingsNoLocale()
{
$user = auth()->user();
$locale = $user->lang_preference ?? config('app.fallback_locale');
// Load the route translations for the specific locale
$routeTranslations = include resource_path("lang/{$locale}/routes.php");
$translatedRoute = $routeTranslations['profile.settings'] ?? 'profile/settings';
$localizedUrl = url("/{$locale}/{$translatedRoute}");
return redirect($localizedUrl . '#message_settings');
}
/**
* Show the user settings screen.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\View\View
*/
public function settings(Request $request)
{
$type = strtolower(class_basename(getActiveProfile()));
if ($type === 'user') {
return view('profile.settings', [
'request' => $request,
'user' => $request->user(),
]);
} else {
return view('profile-' . $type . '.settings');
}
}
public function manage()
{
return view('profiles.manage');
}
/**
* Determines the inactive status flags for a given profile.
*
* @param mixed $profile The profile object to check for inactivity.
* @return array An associative array containing:
* - 'inactive' (bool): Whether the profile is inactive.
* - 'hidden' (bool): Whether the profile should be hidden.
* - 'inactiveLabel' (bool): Whether the profile should be labeled as inactive.
* - 'inactiveSince' (string): Human-readable duration since the profile became inactive.
*/
private function getActiveStates($profile)
{
$inactive = false;
$hidden = false;
$inactiveLabel = false;
$inactiveSince = '';
$emailUnverifiedLabel = false;
$isIncomplete = false;
$incompleteLabel = false;
$noExchangesYetLabel = false;
$removedSince = '';
if (method_exists($profile, 'isActive') && !$profile->isActive()) {
$inactive = true;
if (timebank_config('profile_inactive.profile_hidden')) {
$hidden = true;
}
if (timebank_config('profile_inactive.profile_labeled')) {
$inactiveLabel = true;
$inactiveSince = $profile->inactive_at
? \Illuminate\Support\Carbon::parse($profile->inactive_at)->diffForHumans()
: '';
}
}
if (method_exists($profile, 'isEmailVerified') && !$profile->isEmailVerified()) {
$emailUnverifiedLabel = false;
if (timebank_config('profile_email_unverified.profile_hidden')) {
$hidden = true;
}
if (timebank_config('profile_email_unverified.profile_labeled')) {
$emailUnverifiedLabel = true;
}
}
if (method_exists($profile, 'hasIncompleteProfile') && $profile->hasIncompleteProfile($profile)) {
$isIncomplete = true;
if (timebank_config('profile_incomplete.profile_hidden')) {
$hidden = true;
}
if (timebank_config('profile_incomplete.profile_labeled')) {
$incompleteLabel = true;
}
}
if (
timebank_config('profile_incomplete.no_exchanges_yet_label') &&
method_exists($profile, 'hasNeverReceivedTransaction') &&
$profile->hasNeverReceivedTransaction($profile)
) {
$noExchangesYetLabel = true;
}
if (
(method_exists($profile, 'isRemoved') && $profile->isRemoved()) ||
(!empty($profile->deleted_at) && \Carbon\Carbon::parse($profile->deleted_at)->isPast())
) {
$hidden = true;
$removedSince = !empty($profile->deleted_at)
? \Carbon\Carbon::parse($profile->deleted_at)->diffForHumans()
: '';
}
return compact('inactive', 'hidden', 'inactiveLabel', 'inactiveSince', 'emailUnverifiedLabel', 'isIncomplete', 'incompleteLabel', 'noExchangesYetLabel', 'removedSince');
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ProfileOrgController extends Controller
{
public function show()
{
return view('profile-organization.show');
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ProfileUserController extends Controller
{
public function show()
{
return view('profile-user.show');
}
}

View File

@@ -0,0 +1,319 @@
<?php
namespace App\Http\Controllers;
use App\Traits\AccountInfoTrait;
use App\Models\Transaction;
use Carbon\Carbon;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Spatie\Browsershot\Browsershot;
class ReportController extends Controller
{
use AccountInfoTrait;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
public function reports()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
// This prevents unauthorized access to financial reports via session manipulation
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
$profileAccounts = $this->getAccountsInfo();
return view('reports.show', compact('profileAccounts'));
}
public function downloadPdf(Request $request)
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
// This prevents unauthorized PDF report generation via session manipulation
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
$fromDate = $request->get('fromDate');
$toDate = $request->get('toDate');
$decimalFormat = (bool) $request->get('decimal', 0);
if (!$fromDate || !$toDate) {
abort(400, 'Missing date parameters');
}
// Use the same logic from SingleReport component
$singleReport = new \App\Http\Livewire\SingleReport();
$singleReport->fromDate = $fromDate;
$singleReport->toDate = $toDate;
$singleReport->decimalFormat = $decimalFormat;
$accountsData = $singleReport->getAccountsWithPeriodBalances();
$transactionTypesData = $singleReport->calculateTransactionTypeTotals();
$statisticsData = $singleReport->calculatePeriodStatistics();
$returnRatioData = $singleReport->calculateReturnRatioTimeline();
$returnRatioTimelineData = $returnRatioData['timeline'] ?? [];
$returnRatioTrendData = $returnRatioData['trend'] ?? [];
// Check if we have chart images from the frontend
$chartImage = null; // Legacy single chart support
$returnRatioChartImage = null;
$accountBalancesChartImage = null;
$useChartImage = $request->get('with_chart'); // Legacy single chart
$useChartsImages = $request->get('with_charts'); // New dual charts
Log::info('PDF generation request', [
'useChartImage' => $useChartImage,
'useChartsImages' => $useChartsImages,
'hasSessionChartImageId' => session()->has('chart_image_id'),
'hasSessionChartImageIds' => session()->has('chart_image_ids'),
'sessionChartImageId' => session('chart_image_id'),
'sessionChartImageIds' => session('chart_image_ids')
]);
// Handle new dual charts system
if ($useChartsImages && session('chart_image_ids')) {
$chartImageIds = session('chart_image_ids');
// Process Return Ratio Chart
if (isset($chartImageIds['return_ratio_chart_id'])) {
$chartImageId = $chartImageIds['return_ratio_chart_id'];
$tempImagePath = storage_path('app/temp/' . $chartImageId . '.png');
if (file_exists($tempImagePath)) {
$imageData = file_get_contents($tempImagePath);
$returnRatioChartImage = 'data:image/png;base64,' . base64_encode($imageData);
Log::info('Return Ratio Chart image found in temp file', [
'chartImageId' => $chartImageId,
'fileSize' => strlen($imageData)
]);
// Clean up temporary file
unlink($tempImagePath);
} else {
Log::warning('Return Ratio Chart image temp file not found', ['path' => $tempImagePath]);
}
}
// Process Account Balances Chart
if (isset($chartImageIds['account_balances_chart_id'])) {
$chartImageId = $chartImageIds['account_balances_chart_id'];
$tempImagePath = storage_path('app/temp/' . $chartImageId . '.png');
if (file_exists($tempImagePath)) {
$imageData = file_get_contents($tempImagePath);
$accountBalancesChartImage = 'data:image/png;base64,' . base64_encode($imageData);
Log::info('Account Balances Chart image found in temp file', [
'chartImageId' => $chartImageId,
'fileSize' => strlen($imageData)
]);
// Clean up temporary file
unlink($tempImagePath);
} else {
Log::warning('Account Balances Chart image temp file not found', ['path' => $tempImagePath]);
}
}
session()->forget('chart_image_ids');
}
// Handle legacy single chart system (backward compatibility)
else if ($useChartImage && session('chart_image_id')) {
$chartImageId = session('chart_image_id');
$tempImagePath = storage_path('app/temp/' . $chartImageId . '.png');
if (file_exists($tempImagePath)) {
// Read the image file and convert to base64
$imageData = file_get_contents($tempImagePath);
$chartImage = 'data:image/png;base64,' . base64_encode($imageData);
Log::info('Legacy chart image found in temp file', [
'chartImageId' => $chartImageId,
'fileSize' => strlen($imageData),
'base64Length' => strlen($chartImage)
]);
// Clean up temporary file
unlink($tempImagePath);
session()->forget('chart_image_id');
} else {
Log::warning('Legacy chart image temp file not found', ['path' => $tempImagePath]);
}
} else {
Log::info('No chart images available, using fallback chart rendering');
}
// Prepare chart data for PDF rendering (fallback if no image)
$chartData = $this->prepareChartDataForPdf($returnRatioTimelineData, $returnRatioTrendData);
// Clean UTF-8 encoding for all data
$accountsData = $this->cleanUtf8Data($accountsData);
$transactionTypesData = $this->cleanUtf8Data($transactionTypesData);
$statisticsData = $this->cleanUtf8Data($statisticsData);
// Generate title with profile information
$fromDateCarbon = Carbon::parse($fromDate);
$toDateCarbon = Carbon::parse($toDate);
$activeProfile = getActiveProfile();
$profileTitle = '';
if ($activeProfile) {
$fullName = $activeProfile->full_name ?? $activeProfile->name ?? '';
$profileName = $activeProfile->name ?? '';
$profileTitle = ' ' . $fullName . ' (' . $profileName . ')';
}
$title = [
'header' => __('Financial overview') . $profileTitle,
'sub' => $fromDateCarbon->format('d-m-Y') . ' - ' . $toDateCarbon->format('d-m-Y')
];
$pdf = Pdf::loadView('reports.pdf', [
'title' => $title,
'accountsData' => $accountsData,
'transactionTypesData' => $transactionTypesData,
'statisticsData' => $statisticsData,
'returnRatioTimelineData' => $returnRatioTimelineData,
'returnRatioTrendData' => $returnRatioTrendData,
'chartData' => $chartData,
'chartImage' => $chartImage, // Legacy single chart support
'returnRatioChartImage' => $returnRatioChartImage,
'accountBalancesChartImage' => $accountBalancesChartImage,
'isOrganization' => $activeProfile instanceof \App\Models\Organization,
'decimalFormat' => $decimalFormat,
])->setPaper('A4', 'portrait')
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => false,
'defaultFont' => 'DejaVu Sans',
'dpi' => 150,
'defaultPaperSize' => 'A4',
'chroot' => public_path(),
]);
return $pdf->download('financial-report-' . now()->format('Y-m-d') . '.pdf');
}
/**
* Clean UTF-8 encoding for strings
*/
private function cleanUtf8String($string)
{
if (!is_string($string)) {
return $string;
}
// Remove or replace problematic characters
$string = mb_convert_encoding($string, 'UTF-8', 'UTF-8');
$string = preg_replace('/[\x00-\x1F\x7F-\x9F]/u', '', $string);
return $string;
}
/**
* Clean UTF-8 encoding for arrays/collections
*/
private function cleanUtf8Data($data)
{
if (is_string($data)) {
return $this->cleanUtf8String($data);
}
if (is_array($data) || $data instanceof \Illuminate\Support\Collection) {
$cleaned = [];
foreach ($data as $key => $value) {
$cleanedKey = $this->cleanUtf8String($key);
$cleanedValue = $this->cleanUtf8Data($value);
$cleaned[$cleanedKey] = $cleanedValue;
}
return $data instanceof \Illuminate\Support\Collection ? collect($cleaned) : $cleaned;
}
return $data;
}
/**
* Prepare chart data for PDF rendering
*/
private function prepareChartDataForPdf($returnRatioTimelineData, $returnRatioTrendData)
{
if (!$returnRatioTimelineData || count($returnRatioTimelineData) <= 1) {
return null;
}
$values = array_column($returnRatioTimelineData, 'return_ratio');
$labels = array_column($returnRatioTimelineData, 'label');
// Always start from 0% as requested
$minValue = 0;
$maxValue = count($values) > 0 ? max(array_merge($values, [100])) : 100;
// Add padding to top only
$padding = ($maxValue - $minValue) * 0.1;
$maxValue += $padding;
$range = $maxValue - $minValue;
// Ensure we have a valid range
if ($range <= 0) {
$range = 100;
$maxValue = 100;
}
$pointCount = count($returnRatioTimelineData);
$points = [];
$trendPoints = [];
if ($pointCount > 0 && $range > 0) {
// Calculate data points
foreach ($returnRatioTimelineData as $index => $point) {
$x = $pointCount > 1 ? ($index / ($pointCount - 1)) * 100 : 50;
$y = 100 - (($point['return_ratio'] - $minValue) / $range * 100);
$points[] = number_format($x, 2) . ',' . number_format(max(0, min(100, $y)), 2);
}
// Calculate trend points if available
if ($returnRatioTrendData && count($returnRatioTrendData) > 0) {
$trendCount = count($returnRatioTrendData);
foreach ($returnRatioTrendData as $index => $point) {
$x = $trendCount > 1 ? ($index / ($trendCount - 1)) * 100 : 50;
$y = 100 - (($point['trend_value'] - $minValue) / $range * 100);
$trendPoints[] = number_format($x, 2) . ',' . number_format(max(0, min(100, $y)), 2);
}
}
}
return [
'values' => $values,
'labels' => $labels,
'minValue' => $minValue,
'maxValue' => $maxValue,
'range' => $range,
'points' => $points,
'trendPoints' => $trendPoints,
'pointsString' => implode(' ', $points),
'trendPointsString' => implode(' ', $trendPoints),
];
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers;
use App\Events\ProfileSwitchEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; // Add Hash facade
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str; // Add Str facade
class ResetNonUserPasswordController extends Controller
{
/**
* Display the form to request a password reset link.
*/
public function showLinkRequestForm($profileType)
{
return view('auth.forgot-non-user-password', ['profileType' => $profileType]);
}
/**
* Handle sending the password reset link.
*/
public function sendResetLinkEmail(Request $request, $profileType)
{
$request->validate(['email' => 'required|email']);
$broker = $this->getPasswordBroker($profileType);
// This will now use the model defined in the provider for $broker (e.g., Admin model)
$status = Password::broker($broker)->sendResetLink(
$request->only('email')
);
return $status === Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withErrors(['email' => __($status)]);
}
/**
* Display the password reset view for the given token.
*
* If no token is present, display the link request form.
*/
public function showResetForm(Request $request, $profileType, $token = null)
{
if (is_null($token)) {
return $this->showLinkRequestForm($profileType);
}
$email = $request->query('email');
return view('auth.reset-non-user-password', [
'token' => $token,
'email' => $email,
'profileType' => $profileType
]);
}
/**
* Reset the given profile's password.
*/
public function reset(Request $request, $profileType)
{
// Dynamically get the password validation rules from the config
$passwordRules = timebank_config('rules.profile_' . strtolower($profileType) . '.password', ['required', 'string', 'min:8', 'confirmed']);
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => $passwordRules,
]);
$broker = $this->getPasswordBroker($profileType);
// Attempt to reset the password. This will also use the model defined in the provider.
$status = Password::broker($broker)->reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($profile, $password) {
// $profile will be an instance of Admin, Bank, etc.
$profile->forceFill([
'password' => Hash::make($password),
])->save();
//Log the user in to this elevated profile if that's desired after reset
if ($profile) {
$profileClassName = get_class($profile);
session([
'activeProfileType' => $profileClassName,
'activeProfileId' => $profile->id,
'activeProfileName' => $profile->name,
'activeProfilePhoto' => $profile->profile_photo_path,
'last_activity' => now(),
'profile-switched-notification' => true,
]);
event(new ProfileSwitchEvent($profile));
}
}
);
return $status === Password::PASSWORD_RESET
? redirect()->route('main')->with('status', __($status)) // Or a specific login for that profile type
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
/**
* Get the password broker for the given profile type.
*/
private function getPasswordBroker($profileType)
{
// Ensure this maps to the keys in config/auth.php 'passwords'
$brokers = [
'admin' => 'admins',
'bank' => 'banks',
// 'organization' => 'organizations', // etc.
];
// Fallback to 'users' broker if profileType doesn't match,
// or handle as an error if only specific profile types are allowed here.
return $brokers[strtolower($profileType)] ?? 'users';
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class RoleController extends Controller
{
public function manage()
{
return view('roles.manage');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SearchController extends Controller
{
public function show(Request $request)
{
$authId = Auth::guard('web')->id();
$key = 'main_search_bar_results_' . $authId;
$data = cache()->get($key, ['results' => [], 'searchTerm' => null, 'total' => 0,]);
$resultRefs = $data['results'];
$searchTerm = $data['searchTerm'];
$total = $data['total'];
// empty cache when retrieved
// cache()->forget($key);
return view('search.show', [
'resultRefs' => $resultRefs,
'searchTerm' => $searchTerm,
'total' => $total,
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use Elastic\Elasticsearch\ClientBuilder;
use Illuminate\Support\Facades\Artisan;
class SearchIndexController extends Controller
{
protected $client;
public function __construct()
{
$this->client = ClientBuilder::create()->build();
}
public function indexStopwords($indexName)
{
$stopwords = timebank_config('elasticsearch.stopwords');
$settings = [
'analysis' => [
'analyzer' => [
'my_custom_analyzer' => [
'type' => 'standard',
'stopwords' => $stopwords
]
]
]
];
// Create the index
$params = [
'index' => $indexName,
'body' => [
'settings' => $settings,
// other index settings...
]
];
$response = $this->client->indices()->create($params);
return $response;
}
public function deleteIndex($indexName)
{
$client = ClientBuilder::create()->build();
$params = ['index' => $indexName,
'alias' => $indexName];
$response = $client->indices()->delete($params);
return $response;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Symfony\Component\HttpFoundation\Response;
class StaticController extends Controller
{
/**
* Download the full privacy policy for the current locale
*/
public function downloadPrivacyPolicy(Request $request)
{
$locale = app()->getLocale();
$platform = config('timebank-cc.platform_name', env('TIMEBANK_CONFIG', 'timebank_cc'));
// Get the base path for GDPR references
$basePath = base_path("references/gdpr/{$platform}");
// Find the most recent date folder
$dateFolders = File::directories($basePath);
if (empty($dateFolders)) {
abort(404, 'Privacy policy not found');
}
// Sort folders by name (dates in YYYY-MM-DD format) descending
rsort($dateFolders);
$latestFolder = $dateFolders[0];
// Construct the file path
$fileName = "privacy-policy-FULL-{$locale}.md";
$filePath = "{$latestFolder}/{$fileName}";
if (!File::exists($filePath)) {
abort(404, 'Privacy policy not found for this language');
}
// Return the file as a download
return response()->download($filePath, $fileName, [
'Content-Type' => 'text/markdown',
]);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class TagController extends Controller
{
public function manage()
{
if (getActiveProfileType() !== 'Admin') {
abort(403, __('Admin profile required'));
}
return view('tags.manage');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Artisan;
use Stevebauman\Location\Facades\Location as IpLocation;
class TestController extends Controller
{
/**
* Test Stevbauman/Location package
*
* @return \Illuminate\Http\Response
*/
public function viewIpLocation(Request $request)
{
if (App::environment(['local', 'staging'])) {
$ip = '103.75.231.255'; // Static IP address Brussels for testing
// $ip = '31.20.250.12'; // Statis IP address The Hague for testing
// $ip = '102.129.156.0'; // Statis IP address Berlin for testing
} else {
$ip = $request->ip(); // Dynamic IP address
}
$IpLocationInfo = IpLocation::get($ip);
// TODO: Test for correct ip address in production mode.
// dd($ipLocatonInfo);
// TODO: Enable alternative IpLocation info providers.
return view('test.ip-location', compact('IpLocationInfo'));
}
public function viewDebug1(Request $request)
{
return view('test.debug-1');
}
public function viewDebug2(Request $request)
{
return view('test.debug-2');
}
public function clearCache()
{
Artisan::call('cache:clear');
return "Cache is cleared";
}
public function optimizeClear()
{
Artisan::call('optimize:clear');
return "Events, views, caches, routes, configs cleared";
}
}

View File

@@ -0,0 +1,421 @@
<?php
namespace App\Http\Controllers;
use App\Mail\TransferReceived;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\Transaction;
use App\Models\TransactionType;
use App\Models\User;
use App\Traits\AccountInfoTrait;
use Cog\Laravel\Love\ReactionType\Models\ReactionType as LoveReactionType;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Http\Request;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
use Namu\WireChat\Events\NotifyParticipant;
use Stevebauman\Location\Facades\Location as IpLocation;
class TransactionController extends Controller
{
use AccountInfoTrait;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
public function pay()
{
$toName = null;
return view('pay.show', compact(['toName']));
}
public function payToName($name = null)
{
return view('pay.show', compact(['name']));
}
public function payAmountToName($hours = null, $minutes = null, $name = null)
{
return view('pay.show', compact(['hours', 'minutes', 'name']));
}
public function payAmountToNameWithDescr($hours = null, $minutes = null, $name = null, $description = null)
{
return view('pay.show', compact(['hours', 'minutes', 'name', 'description']));
}
// Legacy Cyclos payment link, as used by Lekkernasuh
public function doCyclosPayment(Request $request, $minutes = null, $toAccoundId = null, $name = null, $description = null, $type = null)
{
$cyclos_id = $request->query('to');
// Amount is in integer minutes
$minutes = (int) $request->query('amount');
$toAccountId = null;
$name = null;
if ($cyclos_id) {
$accounts = Account::accountsCyclosMember($cyclos_id);
if (count($accounts) > 1) {
// More than 1 account found with this cyclos_id — search by name instead
$name = $this->getNameByCyclosId($cyclos_id);
} else {
$toAccountId = $accounts->keys()->first();
}
}
$description = $request->query('description');
$type = $request->query('type');
return view('pay.show', compact(['minutes', 'toAccountId', 'name', 'description', 'type']));
}
public static function getNameByCyclosId($cyclos_id)
{
$user = User::where('cyclos_id', $cyclos_id)->first();
if ($user) {
return $user->name;
}
$organization = Organization::where('cyclos_id', $cyclos_id)->first();
if ($organization) {
return $organization->name;
}
$bank = Bank::where('cyclos_id', $cyclos_id)->first();
if ($bank) {
return $bank->name;
}
return null;
}
public function transactions()
{
$profileAccounts = $this->getAccountsInfo();
return view('transactions.show', compact('profileAccounts'));
}
public function statement($transactionId)
{
$results = Transaction::with('accountTo.accountable', 'accountFrom.accountable')
->where('id', $transactionId)
->whereHas('accountTo', function ($query) {
$query->where('accountable_type', Session('activeProfileType'))
->where('accountable_id', Session('activeProfileId'));
})
->orWhereHas('accountFrom.accountable', function ($query) {
$query->where('accountable_type', Session('activeProfileType'))
->where('accountable_id', Session('activeProfileId'));
})
->find($transactionId);
$qrModalVisible = request()->query('qrModalVisible', false);
//TODO: add permission check
//TODO: if 403, but has permission, redirect with message to switch profile
//TODO: replace 403 with custom redirect page incl explanation
// Check if the transaction exists
if ($transactionId) {
// Pass the transactionId and transaction details to the view
return view('transactions.statement', compact('transactionId', 'qrModalVisible'));
} else {
// Abort with a 403 status code if the transaction does not exist
// TODO to static page explaining that the transactionId is not accessible for current profile.
return abort(403, 'Unauthorized action.');
}
}
/**
* Create a new transaction with all necessary checks.
* This is a non-Livewire version of the transfer logic.
*
* @param int $fromAccountId
* @param int $toAccountId
* @param int $amount
* @param string $description
* @param int $transactionTypeId
* @return \Illuminate\Http\RedirectResponse
*/
public function createTransfer($fromAccountId, $toAccountId, $amount, $description, $transactionTypeId)
{
// 1. SECURITY CHECKS
$fromAccountableType = Account::find($fromAccountId)->accountable_type;
$fromAccountableId = Account::find($fromAccountId)->accountable_id;
$accountsInfo = collect($this->getAccountsInfo($fromAccountableType, $fromAccountableId));
if (!$accountsInfo->contains('id', $fromAccountId)) {
return $this->logAndReport('Unauthorized payment attempt: illegal access of From account', $fromAccountId, $toAccountId, $amount, $description);
}
// Check if From and To Account is different
if ($toAccountId == $fromAccountId) {
return $this->logAndReport('Impossible payment attempt: To and From account are the same', $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the To Account exists and is not removed
$toAccount = Account::notRemoved()->find($toAccountId);
if (!$toAccount) {
return $this->logAndReport('Impossible payment attempt: To account not found', $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the To Accountable exists and is not removed
$toAccountableExists = Account::find($toAccountId)->accountable()->notRemoved()->first();
if (!$toAccountableExists) {
$warningMessage = 'Impossible payment attempt: To account holder not found';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the From Account exists and is not removed
$fromAccount = Account::notRemoved()->find($fromAccountId);
if (!$fromAccount) {
return $this->logAndReport('Impossible payment attempt: From account not found', $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the From Accountable exists and is not removed
$fromAccountableExists = Account::find($fromAccountId)->accountable()->notRemoved()->first();
if (!$fromAccountableExists) {
$warningMessage = 'Impossible payment attempt: From account holder not found';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the transactionTypeSelected is allowed, with an exception for internal migration (type 6)
$toHolderType = $toAccount->accountable_type;
$toHolderId = $toAccount->accountable_id;
$isInternalMigration = (
$fromAccountableType === $toHolderType &&
$fromAccountableId == $toHolderId
);
// Only perform the check if it's not the specific internal migration case
if (!$isInternalMigration) {
$canReceive = timebank_config('accounts.' . strtolower(class_basename($toHolderType)) . '.receiving_types');
$canPay = timebank_config('permissions.' . strtolower(class_basename($fromAccountableType)) . '.payment_types');
$allowedTypeIds = array_intersect($canPay, $canReceive);
if (!in_array($transactionTypeId, $allowedTypeIds)) {
$transactionTypeName = TransactionType::find($transactionTypeId)->name ?? 'id: ' . $transactionTypeId;
return $this->logAndReport('Impossible payment attempt: transaction type not allowed', $fromAccountId, $toAccountId, $amount, $description, $transactionTypeName);
}
} else {
$transactionTypeId = 6; // Enforce transactionTypeId 6 (migration) for internal transactions
}
// 2. BALANCE LIMIT CHECKS
$fromAccount = Account::find($fromAccountId);
$limitMinFrom = $fromAccount->limit_min;
$effectiveLimitMaxTo = $toAccount->limit_max - $toAccount->limit_min;
$balanceFrom = $this->getBalance($fromAccountId);
$balanceTo = $this->getBalance($toAccountId);
$transferBudgetFrom = $balanceFrom - $limitMinFrom;
$balanceToPublic = timebank_config('account_info.' . strtolower(class_basename($toHolderType)) . '.balance_public');
$transferBudgetTo = $effectiveLimitMaxTo - $balanceTo;
$limitError = $this->checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic, $toAccount->accountable->name);
if ($limitError) {
return redirect()->back()->with('error', $limitError);
}
// 3. CLEAN UP DESCRIPTION
// Get the validation rule string from the config
$rule = timebank_config('payment.description_rule');
preg_match('/max:(\d+)/', $rule, $matches);
$maxLength = $matches[1] ?? 500;
// Use Str::limit to truncate the description if it's too long, adding ' ...'
$description = \Illuminate\Support\Str::limit($description, $maxLength, ' ...');
// 4. DATABASE TRANSACTION
DB::beginTransaction();
try {
$transfer = new Transaction();
$transfer->from_account_id = $fromAccountId;
$transfer->to_account_id = $toAccountId;
$transfer->amount = $amount;
$transfer->description = $description;
$transfer->transaction_type_id = $transactionTypeId;
$transfer->creator_user_id = null;
$save = $transfer->save();
if ($save) {
DB::commit();
// 4. POST-TRANSACTION ACTIONS
// Send chat message and an email if conditions are met
$recipient = $transfer->accountTo->accountable;
$sender = $transfer->accountFrom->accountable;
$messageLocale = $recipient->lang_preference ?? $sender->lang_preference;
if (!Lang::has('messages.pay_chat_message', $messageLocale)) { // Check if the translation key exists for the selected locale
$messageLocale = config('app.fallback_locale'); // Fallback to the app's default locale
}
// Translate the account name using the RECIPIENT'S language
$toAccountName = __(ucfirst(strtolower($transfer->accountTo->name)), [], $messageLocale);
$chatMessage = __('messages.pay_chat_message', [
'amount' => tbFormat($amount),
'account_name' => $toAccountName,
], $messageLocale);
$chatTransactionStatement = LaravelLocalization::getURLFromRouteNameTranslated($messageLocale, 'routes.statement', array('transactionId' => $transfer->id));
// Send Wirechat message
$message = $sender->sendMessageTo($recipient, $chatMessage);
$message = $sender->sendMessageTo($recipient, $chatTransactionStatement);
// Broadcast the NotifyParticipant event to wirechat messenger
broadcast(new NotifyParticipant($recipient, $message));
// Check if the recipient has message settings for receiving this email and has also an email address
if (isset($recipient->email)) {
$messageSettings = method_exists($recipient, 'message_settings') ? $recipient->message_settings()->first() : null;
// Always send email unless payment_received is explicitly false
if (!$messageSettings || !($messageSettings->payment_received === false || $messageSettings->payment_received === 0)) {
$now = now();
Mail::to($recipient->email)->later($now->addSeconds(0), new TransferReceived($transfer, $messageLocale));
info(1);
}
}
// Add Love Reaction with transaction type name on both models
$reactionType = TransactionType::find($transactionTypeId)->name;
try {
$reacterFacadeSender = $sender->viaLoveReacter()->reactTo($recipient, $reactionType);
$reacterFacadeRecipient = $recipient->viaLoveReacter()->reactTo($sender, $reactionType);
} catch (\Exception $e) {
// Ignore if reaction type does not exist
}
$successMessage = tbFormat($amount) . __('was paid to the ') . $toAccountName . __(' of ') . $recipient->name . '.' . '<br /><br />' . '<a href="' . route('transaction.show', ['transactionId' => $transfer->id]) . '">' . __('Show Transaction # ') . $transfer->id . '</a>';
return redirect()->back()->with('success', $successMessage);
} else {
throw new \Exception('Transaction could not be saved');
}
} catch (\Exception $e) {
DB::rollBack();
$this->logAndReport('Transaction failed', $fromAccountId, $toAccountId, $amount, $description, '', $e->getMessage());
return redirect()->back()->with('error', __('Sorry we have an error: this transaction could not be saved!'));
}
}
/**
* Helper to check balance limits for a transfer operation.
* Returns an error string if a limit is exceeded, otherwise null.
*/
private function checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic, $toHolderName)
{
if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom <= $transferBudgetTo) {
return __('messages.pay_limit_error_budget_from', [
'limitMinFrom' => tbFormat($limitMinFrom),
'transferBudgetFrom' => tbFormat($transferBudgetFrom),
]);
}
if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom > $transferBudgetTo) {
return $balanceToPublic
? __('messages.pay_limit_error_budget_from_and_to', [
'limitMinFrom' => tbFormat($limitMinFrom),
'transferBudgetTo' => tbFormat($transferBudgetTo),
])
: __('messages.pay_limit_error_budget_from_and_to_without_budget_to', [
'limitMinFrom' => tbFormat($limitMinFrom),
]);
}
if ($amount > $transferBudgetFrom) {
return __('messages.pay_limit_error_budget_from', [
'limitMinFrom' => tbFormat($limitMinFrom),
'transferBudgetFrom' => tbFormat($transferBudgetFrom),
]);
}
if ($amount > $transferBudgetTo) {
return $balanceToPublic
? __('messages.pay_limit_error_budget_to', [
'transferBudgetTo' => tbFormat($transferBudgetTo),
])
: __('messages.pay_limit_error_budget_to_without_budget_to', [
'transferBudgetTo' => tbFormat($transferBudgetTo),
'toHolderName' => $toHolderName,
]);
}
return null; // No error
}
/**
* Helper to log a warning message and report it via email to the system administrator.
* Returns a redirect response with an error message.
*/
private function logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description, $transactionType = '', $error = '')
{
$ip = request()->ip();
$ipLocationInfo = IpLocation::get($ip);
if (!$ipLocationInfo || App::environment(['local', 'development', 'staging'])) {
$ipLocationInfo = (object) ['cityName' => 'local City', 'regionName' => 'local Region', 'countryName' => 'local Country'];
}
$eventTime = now()->toDateTimeString();
$fromAccountHolder = Account::find($fromAccountId)->accountable()->value('name') ?? 'N/A';
$toAccountHolder = Account::find($toAccountId)->accountable()->value('name') ?? 'N/A';
Log::warning($warningMessage, [
'fromAccountId' => $fromAccountId, 'fromAccountHolder' => $fromAccountHolder,
'toAccountId' => $toAccountId, 'toAccountHolder' => $toAccountHolder,
'amount' => $amount,
'description' => $description,
'userId' => Auth::id(), 'userName' => Auth::user()->name,
'activeProfileId' => session('activeProfileId'), 'activeProfileType' => session('activeProfileType'), 'activeProfileName' => session('activeProfileName'),
'transactionType' => ucfirst($transactionType),
'IP address' => $ip, 'IP location' => "{$ipLocationInfo->cityName}, {$ipLocationInfo->regionName}, {$ipLocationInfo->countryName}",
'Event Time' => $eventTime, 'Message' => $error,
]);
$rawMessage = "{$warningMessage}.\n\n" .
"From Account ID: {$fromAccountId}\nFrom Account Holder: {$fromAccountHolder}\n" .
"To Account ID: {$toAccountId}\nTo Account Holder: {$toAccountHolder}\n" .
"Amount: {$amount}\n" .
"Description: {$description}\n" .
"User ID: " . Auth::id() . "\nUser Name: " . Auth::user()->name . "\n" .
"Active Profile ID: " . session('activeProfileId') . "\nActive Profile Type: " . session('activeProfileType') . "\nActive Profile Name: " . session('activeProfileName') . "\n" .
"Transaction Type: " . ucfirst($transactionType) . "\n" .
"IP address: {$ip}\nIP location: {$ipLocationInfo->cityName}, {$ipLocationInfo->regionName}, {$ipLocationInfo->countryName}\n" .
"Event Time: {$eventTime}\n\n{$error}";
Mail::raw($rawMessage, function ($message) use ($warningMessage) {
$message->to(timebank_config('mail.system_admin.email'))->subject($warningMessage);
});
return redirect()->back()->with('error', __($warningMessage) . '. ' . __('This event has been logged and reported to our system administrator') . '.');
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
class UserLoginController extends Controller
{
/**
* Direct link to user login - can be used in emails
* Handles the authentication flow:
* 1. If user not authenticated -> redirect to user login with intended URL
* 2. If user authenticated but wrong user -> 403 forbidden
* 3. If correct user -> redirect to intended URL or main page
* 4. Supports custom intended URL via query parameter
*/
public function directLogin(Request $request, $userId)
{
\Log::info('UserLoginController: directLogin called', [
'user_id' => $userId,
'request_url' => $request->fullUrl(),
'all_params' => $request->all(),
]);
// Validate user exists
$user = User::find($userId);
if (!$user) {
abort(404, __('User not found'));
}
// Get optional intended destination after successful login
// Default to main page if not specified
$intendedUrl = $request->query('intended');
if (!$intendedUrl) {
$intendedUrl = LaravelLocalization::localizeURL(
route('main'),
$user->lang_preference ?? config('app.fallback_locale')
);
}
// Check if user is authenticated on web guard
\Log::info('UserLoginController: Checking authentication', [
'is_authenticated' => Auth::guard('web')->check(),
]);
if (!Auth::guard('web')->check()) {
// User not logged in - redirect to user login with return URL
$returnUrl = LaravelLocalization::localizeURL(
route('user.direct-login', ['userId' => $userId]),
$user->lang_preference ?? config('app.fallback_locale')
);
if ($intendedUrl) {
$returnUrl .= '?intended=' . urlencode($intendedUrl);
}
// Get the name parameter from the current request to pass along
$nameParam = $request->query('name', $user->name);
\Log::info('UserLoginController: Redirecting to login', [
'return_url' => $returnUrl,
'intended_url' => $intendedUrl,
'prefill_username' => $nameParam,
]);
// Store in session for Laravel to redirect after login
session()->put('url.intended', $returnUrl);
// Pass username as URL parameter to pre-fill login form
// Use LaravelLocalization to ensure the parameter is preserved through localization
$loginUrl = LaravelLocalization::localizeURL(
route('login'),
$user->lang_preference ?? config('app.fallback_locale')
);
$loginUrl .= '?name=' . urlencode($nameParam);
\Log::info('UserLoginController: Redirecting to login with name parameter', [
'login_url' => $loginUrl,
'username' => $nameParam,
]);
return redirect()->to($loginUrl, 302, [], false);
}
// User is authenticated - verify they are the correct user
$authenticatedUser = Auth::guard('web')->user();
if ($authenticatedUser->id !== $user->id) {
abort(403, __('You do not have access to this profile'));
}
// Re-activate profile if inactive
if (timebank_config('profile_inactive.re-activate_at_login')) {
if (!$user->isActive()) {
$user->inactive_at = null;
$user->save();
info('User re-activated: ' . $user->name);
}
}
\Log::info('UserLoginController: Authenticated user verified, redirecting', [
'user_id' => $authenticatedUser->id,
'target_user_id' => $user->id,
'intended_url' => $intendedUrl,
]);
// Redirect to intended URL
return redirect($intendedUrl);
}
}