Initial commit: CABM-IT (ItGlue-style MSP documentation platform)

Laravel 13 + Livewire/Volt app for managing client organizations,
documents, passwords, flexible assets, virtual machines, contacts,
domains, todos, shortcuts, and role-based user administration.
This commit is contained in:
jeremy bayse
2026-08-16 13:28:12 +02:00
commit 8f22dca9c8
209 changed files with 23324 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
],
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Facades\Crypt;
/**
* AES-256-GCM encrypt/decrypt via the app's Crypt facade.
*
* Simplification: uses the single app-wide key as the encryption key (KEK == DEK).
* Production hardening: wrap a per-organization DEK with a KMS-held KEK (envelope
* encryption) so a single leaked app key doesn't expose every tenant's secrets.
*/
class EncryptedField implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
if ($value === null) {
return null;
}
try {
return Crypt::decryptString($value);
} catch (\Illuminate\Contracts\Encryption\DecryptException) {
return null;
}
}
public function set($model, string $key, $value, array $attributes)
{
return $value === null ? null : Crypt::encryptString($value);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Contracts;
/**
* Implemented by any model that can appear in the Relationship Mapping panel
* (related_items). Lets the panel render a link + label without a type switch.
*/
interface Linkable
{
public function linkableKind(): string;
public function linkableLabel(): string;
public function linkableUrl(): string;
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Models\Document;
use App\Models\Organization;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DocumentDownloadController extends Controller
{
public function __invoke(Request $request, Organization $organization, Document $document): StreamedResponse
{
abort_unless($document->organization_id === $organization->id, 404);
abort_unless($document->isUpload() && $document->file_path, 404);
if ($request->boolean('inline')) {
return Storage::response($document->file_path, $document->name);
}
return Storage::download($document->file_path, $document->name);
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\Document;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class OnlyOfficeController extends Controller
{
public function download(Request $request, Document $document): StreamedResponse
{
// Relative signature: the download URL is built with a host the document
// server can reach (host.docker.internal, ...), which may differ from
// the host the browser/this server sees — only path+query are signed.
abort_unless($request->hasValidRelativeSignature(), 403);
abort_unless($document->isOffice() && $document->file_path, 404);
return Storage::response($document->file_path);
}
public function callback(Request $request, Document $document): JsonResponse
{
abort_unless($document->isOffice(), 404);
$payload = $this->verifiedPayload($request);
// OnlyOffice callback statuses: 2 = ready to save, 6 = force-saved while editing.
if (in_array($payload['status'] ?? null, [2, 6], true) && filled($payload['url'] ?? null)) {
$response = Http::get($payload['url']);
abort_unless($response->successful(), 502);
Storage::put($document->file_path, $response->body());
$document->increment('current_version');
$document->update(['office_key' => Str::random(20)]);
$document->versions()->create([
'version' => $document->current_version,
'created_by' => $payload['users'][0] ?? null,
]);
}
return response()->json(['error' => 0]);
}
protected function verifiedPayload(Request $request): array
{
$secret = config('onlyoffice.jwt_secret');
if (blank($secret)) {
return $request->all();
}
$token = $request->bearerToken()
?? $request->input('token')
?? str($request->header('Authorization', ''))->after('Bearer ')->toString();
abort_if(blank($token), SymfonyResponse::HTTP_UNAUTHORIZED);
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
return (array) ($decoded->payload ?? $decoded);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
class CheckOrganizationIpAccess
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$organization = $request->route('organization');
if (! $organization || empty($organization->allowed_ip_ranges)) {
return $next($request);
}
if (IpUtils::checkIp($request->ip(), $organization->allowed_ip_ranges)) {
return $next($request);
}
abort(403, 'Your IP address is not permitted to access this organization.');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'organization_id', 'location_id', 'first_name', 'last_name',
'title', 'email', 'phone', 'mobile', 'is_important', 'notes',
])]
class Contact extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'is_important' => 'boolean',
];
}
protected function fullName(): Attribute
{
return Attribute::get(fn () => trim("{$this->first_name} {$this->last_name}"));
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function location()
{
return $this->belongsTo(Location::class);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Models;
use App\Contracts\Linkable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use League\CommonMark\CommonMarkConverter;
#[Fillable(['organization_id', 'folder_id', 'name', 'type', 'is_folder', 'content', 'file_path', 'office_extension', 'office_key', 'current_version', 'created_by'])]
class Document extends Model implements Linkable
{
use SoftDeletes;
protected function casts(): array
{
return [
'is_folder' => 'boolean',
];
}
public function isOffice(): bool
{
return $this->type === 'office';
}
public function isMarkdown(): bool
{
return $this->type === 'markdown';
}
public function isUpload(): bool
{
return $this->type === 'upload';
}
public function renderedMarkdown(): string
{
return (new CommonMarkConverter())->convert($this->content ?? '')->getContent();
}
public function isFolder(): bool
{
return $this->is_folder || $this->children()->exists();
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function folder()
{
return $this->belongsTo(Document::class, 'folder_id');
}
public function children()
{
return $this->hasMany(Document::class, 'folder_id');
}
public function versions()
{
return $this->hasMany(DocumentVersion::class)->orderByDesc('version');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function relatedItems()
{
return RelatedItem::forItem($this);
}
public function linkableKind(): string
{
return 'Document';
}
public function linkableLabel(): string
{
return $this->name;
}
public function linkableUrl(): string
{
return route('organizations.documents.edit', [$this->organization_id, $this]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DocumentVersion extends Model
{
const UPDATED_AT = null;
protected $guarded = [];
public function document()
{
return $this->belongsTo(Document::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['organization_id', 'name', 'registrar', 'nameservers', 'expires_at', 'auto_renew', 'last_checked_at', 'notes'])]
class Domain extends Model
{
protected function casts(): array
{
return [
'expires_at' => 'date',
'auto_renew' => 'boolean',
'last_checked_at' => 'datetime',
];
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function sslCertificates()
{
return $this->hasMany(SslCertificate::class);
}
public function tld(): ?string
{
$parts = explode('.', $this->name);
return count($parts) > 1 ? '.'.end($parts) : null;
}
public function expiresInDays(): ?int
{
return $this->expires_at ? now()->startOfDay()->diffInDays($this->expires_at, false) : null;
}
public function expiryStatus(): string
{
$days = $this->expiresInDays();
return match (true) {
$days === null => 'unknown',
$days < 0 => 'expired',
$days <= 30 => 'soon',
default => 'ok',
};
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use App\Contracts\Linkable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable(['flexible_asset_type_id', 'organization_id', 'name', 'data', 'is_archived', 'created_by'])]
class FlexibleAsset extends Model implements Linkable
{
use SoftDeletes;
protected function casts(): array
{
return [
'data' => 'array',
'is_archived' => 'boolean',
];
}
public function type()
{
return $this->belongsTo(FlexibleAssetType::class, 'flexible_asset_type_id');
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function relatedItems()
{
return RelatedItem::forItem($this);
}
public function logs()
{
return $this->hasMany(FlexibleAssetLog::class)->orderByDesc('created_at');
}
public function linkableKind(): string
{
return $this->type?->name ?? 'Flexible Asset';
}
public function linkableLabel(): string
{
return $this->name;
}
public function linkableUrl(): string
{
return route('organizations.assets.edit', [$this->organization_id, $this->flexible_asset_type_id, $this]);
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
#[Fillable([
'flexible_asset_type_id', 'name', 'label', 'field_type',
'options', 'is_required', 'show_in_list', 'sort_order',
])]
class FlexibleAssetField extends Model
{
const FIELD_TYPES = ['text', 'textarea', 'password', 'checkbox', 'date', 'tag', 'select', 'related_asset'];
/**
* Built-in record lists a "related_asset" field can point to.
* Keys are stored in options.target; values map to an Organization relation
* name and the attribute used as the display label.
*/
const RELATED_TARGETS = [
'contact' => ['relation' => 'contacts', 'label' => 'full_name'],
'location' => ['relation' => 'locations', 'label' => 'name'],
'domain' => ['relation' => 'domains', 'label' => 'name'],
];
protected function casts(): array
{
return [
'options' => 'array',
'is_required' => 'boolean',
'show_in_list' => 'boolean',
];
}
public function type()
{
return $this->belongsTo(FlexibleAssetType::class, 'flexible_asset_type_id');
}
public function relatedTarget(): ?string
{
return $this->options['target'] ?? null;
}
/**
* Records available for this field's related_asset target, scoped to the organization.
* @return Collection<int, array{id: int, label: string}>
*/
public function relatedOptions(Organization $organization): Collection
{
$target = $this->relatedTarget();
if (! $target) {
return collect();
}
if (str_starts_with($target, 'flexible_asset:')) {
$typeId = (int) substr($target, strlen('flexible_asset:'));
return $organization->flexibleAssets()
->where('flexible_asset_type_id', $typeId)
->orderBy('name')
->get()
->map(fn (FlexibleAsset $asset) => ['id' => $asset->id, 'label' => $asset->name]);
}
$config = self::RELATED_TARGETS[$target] ?? null;
if (! $config) {
return collect();
}
return $organization->{$config['relation']}()
->orderBy($config['label'] === 'full_name' ? 'last_name' : $config['label'])
->get()
->map(fn (Model $record) => ['id' => $record->id, 'label' => (string) $record->{$config['label']}]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class FlexibleAssetLog extends Model
{
const UPDATED_AT = null;
protected $guarded = [];
public function flexibleAsset()
{
return $this->belongsTo(FlexibleAsset::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name', 'icon', 'description', 'enabled_for_all_organizations'])]
class FlexibleAssetType extends Model
{
protected function casts(): array
{
return [
'enabled_for_all_organizations' => 'boolean',
];
}
public function fields()
{
return $this->hasMany(FlexibleAssetField::class)->orderBy('sort_order');
}
public function assets()
{
return $this->hasMany(FlexibleAsset::class);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'organization_id', 'name', 'address_line_1', 'address_line_2',
'city', 'region', 'postal_code', 'country', 'phone', 'is_primary',
])]
class Location extends Model
{
protected function casts(): array
{
return [
'is_primary' => 'boolean',
];
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function contacts()
{
return $this->hasMany(Contact::class);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path'])]
class Organization extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'allowed_ip_ranges' => 'array',
];
}
protected function logoUrl(): Attribute
{
return Attribute::get(
fn () => $this->logo_path ? Storage::disk('public')->url($this->logo_path) : asset('images/default-organization-logo.svg')
);
}
public function users()
{
return $this->belongsToMany(User::class);
}
public function locations()
{
return $this->hasMany(Location::class);
}
public function contacts()
{
return $this->hasMany(Contact::class);
}
public function passwords()
{
return $this->hasMany(Password::class);
}
public function flexibleAssets()
{
return $this->hasMany(FlexibleAsset::class);
}
public function documents()
{
return $this->hasMany(Document::class);
}
public function domains()
{
return $this->hasMany(Domain::class);
}
public function sslCertificates()
{
return $this->hasMany(SslCertificate::class);
}
public function virtualMachines()
{
return $this->hasMany(VirtualMachine::class);
}
public function logEntries()
{
return $this->hasMany(OrganizationLogEntry::class)->orderByDesc('created_at');
}
public function todos()
{
return $this->hasMany(Todo::class);
}
public function shortcuts()
{
return $this->hasMany(Shortcut::class);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class OrganizationLogEntry extends Model
{
const UPDATED_AT = null;
const CATEGORIES = [
'project' => 'Projet en cours',
'attention' => "Point d'attention",
'intervention' => 'Intervention prévue',
'info' => 'Information générale',
];
protected $guarded = [];
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function categoryLabel(): string
{
return self::CATEGORIES[$this->category] ?? $this->category;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Models;
use App\Casts\EncryptedField;
use App\Contracts\Linkable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'organization_id', 'resource_type', 'resource_id', 'name', 'username',
'password_ciphertext', 'url', 'notes', 'otp_secret', 'is_favorite',
'password_updated_at', 'rotation_due_at', 'created_by',
])]
class Password extends Model implements Linkable
{
use SoftDeletes;
protected function casts(): array
{
return [
'password_ciphertext' => EncryptedField::class,
'otp_secret' => EncryptedField::class,
'is_favorite' => 'boolean',
'password_updated_at' => 'datetime',
'rotation_due_at' => 'datetime',
];
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function resource()
{
return $this->morphTo();
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function auditLogs()
{
return $this->hasMany(PasswordAuditLog::class);
}
public function logAction(string $action): void
{
$this->auditLogs()->create([
'user_id' => auth()->id(),
'action' => $action,
'ip_address' => request()->ip(),
'user_agent' => (string) request()->userAgent(),
]);
}
public function relatedItems()
{
return RelatedItem::forItem($this);
}
public function linkableKind(): string
{
return 'Password';
}
public function linkableLabel(): string
{
return $this->name;
}
public function linkableUrl(): string
{
return route('organizations.passwords.edit', [$this->organization_id, $this]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PasswordAuditLog extends Model
{
const UPDATED_AT = null;
protected $guarded = [];
public function password()
{
return $this->belongsTo(Password::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class RelatedItem extends Model
{
protected $guarded = [];
public function item(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'item_type', 'item_id');
}
public function relatedItem(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'related_item_type', 'related_item_id');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* All edges touching a given model, either as owner or as target (symmetric view).
*/
public static function forItem(Model $model)
{
return static::where(function ($query) use ($model) {
$query->where('item_type', $model->getMorphClass())->where('item_id', $model->getKey());
})->orWhere(function ($query) use ($model) {
$query->where('related_item_type', $model->getMorphClass())->where('related_item_id', $model->getKey());
});
}
/**
* The model on the "other side" of this edge, relative to the given model.
*/
public function otherItem(Model $relativeTo): ?Model
{
$isOwner = $this->item_type === $relativeTo->getMorphClass() && (string) $this->item_id === (string) $relativeTo->getKey();
return $isOwner ? $this->relatedItem : $this->item;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['organization_id', 'label', 'url', 'icon', 'created_by'])]
class Shortcut extends Model
{
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function isGlobal(): bool
{
return $this->organization_id === null;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['organization_id', 'domain_id', 'name', 'issuer', 'issued_at', 'expires_at', 'last_checked_at'])]
class SslCertificate extends Model
{
protected function casts(): array
{
return [
'issued_at' => 'date',
'expires_at' => 'date',
'last_checked_at' => 'datetime',
];
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function domain()
{
return $this->belongsTo(Domain::class);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use App\Contracts\Linkable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'organization_id', 'title', 'description', 'category', 'criticality',
'due_date', 'assigned_to', 'is_done', 'completed_at', 'created_by',
])]
class Todo extends Model implements Linkable
{
use SoftDeletes;
const CATEGORIES = [
'infra' => 'Infra',
'support' => 'Support',
'metier' => 'Métier',
'autre' => 'Autre',
];
const CRITICALITIES = [
'low' => 'Basse',
'medium' => 'Moyenne',
'high' => 'Haute',
'urgent' => 'Urgente',
];
protected function casts(): array
{
return [
'due_date' => 'date',
'is_done' => 'boolean',
'completed_at' => 'datetime',
];
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function assignee()
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function categoryLabel(): string
{
return self::CATEGORIES[$this->category] ?? $this->category;
}
public function criticalityLabel(): string
{
return self::CRITICALITIES[$this->criticality] ?? $this->criticality;
}
public function isOverdue(): bool
{
return ! $this->is_done && $this->due_date && $this->due_date->isPast();
}
public function linkableKind(): string
{
return 'Todo';
}
public function linkableLabel(): string
{
return $this->title;
}
public function linkableUrl(): string
{
return route('organizations.todos.edit', [$this->organization_id, $this]);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
#[Fillable(['name', 'email', 'password', 'is_msp_staff'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasApiTokens, HasRoles, TwoFactorAuthenticatable;
public function organizations()
{
return $this->belongsToMany(Organization::class);
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_msp_staff' => 'boolean',
'last_login_at' => 'datetime',
];
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\Contracts\Linkable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'organization_id', 'name', 'role', 'memory_gb', 'cpu_count',
'operating_system', 'notes', 'created_by',
])]
class VirtualMachine extends Model implements Linkable
{
use SoftDeletes;
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function logs()
{
return $this->hasMany(VirtualMachineLog::class)->orderByDesc('created_at');
}
public function linkableKind(): string
{
return 'VirtualMachine';
}
public function linkableLabel(): string
{
return $this->name;
}
public function linkableUrl(): string
{
return route('organizations.virtual-machines.edit', [$this->organization_id, $this]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VirtualMachineLog extends Model
{
const UPDATED_AT = null;
protected $guarded = [];
public function virtualMachine()
{
return $this->belongsTo(VirtualMachine::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
Fortify::loginView(fn () => view('auth.login'));
Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password'));
Fortify::resetPasswordView(fn (Request $request) => view('auth.reset-password', ['request' => $request]));
Fortify::twoFactorChallengeView(fn () => view('auth.two-factor-challenge'));
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('passkeys', function (Request $request) {
$credentialId = $request->input('credential.id');
return Limit::perMinute(10)->by(
($credentialId ?: $request->session()->getId()).'|'.$request->ip()
);
});
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Services\OnlyOffice;
use App\Models\Document;
use App\Models\User;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\URL;
class DocumentConfigBuilder
{
public function build(Document $document, User $user, bool $editable = true): array
{
$config = [
'document' => [
'fileType' => $document->office_extension,
'key' => $document->office_key,
'title' => $document->name.'.'.$document->office_extension,
'url' => $this->downloadUrl($document),
],
'documentType' => $this->documentType($document->office_extension),
'editorConfig' => [
'mode' => $editable ? 'edit' : 'view',
'callbackUrl' => $this->callbackUrl($document),
'user' => [
'id' => (string) $user->id,
'name' => $user->name,
],
'lang' => 'fr',
],
];
$secret = config('onlyoffice.jwt_secret');
if (filled($secret)) {
$config['token'] = JWT::encode($config, $secret, 'HS256');
}
return $config;
}
protected function downloadUrl(Document $document): string
{
$signed = URL::temporarySignedRoute(
'onlyoffice.download',
now()->addMinutes(5),
['document' => $document->id],
absolute: false
);
return $this->baseUrl().$signed;
}
protected function callbackUrl(Document $document): string
{
return $this->baseUrl().route('onlyoffice.callback', $document, absolute: false);
}
protected function baseUrl(): string
{
return rtrim(config('onlyoffice.callback_base_url'), '/');
}
protected function documentType(?string $extension): string
{
return match ($extension) {
'xlsx' => 'cell',
'pptx' => 'slide',
default => 'word',
};
}
}