Sync email team invites, meeting room fixes, Afia, and org settings.
Deploy Ladill Meet / deploy (push) Successful in 47s

Publish monorepo meet changes: identity API invites, join/room session fixes,
Afia assistant panel, settings nav, and SSO team provisioning.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 06:45:35 +00:00
co-authored by Cursor
parent 3348d418b8
commit a9d3a8bd64
23 changed files with 829 additions and 35 deletions
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Identity\IdentityTeamClient;
use App\Services\Meet\TeamMemberProvisioner;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -26,12 +28,14 @@ class SsoLoginController extends Controller
*/
private const MAX_SSO_ATTEMPTS = 3;
public function connect(Request $request): RedirectResponse|View
public function connect(Request $request, IdentityTeamClient $identity): RedirectResponse|View
{
$intended = (string) $request->query('redirect', route('meet.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('meet.dashboard'));
$redirect = $this->resolveLanding($identity, $request->user(), $intended);
return $this->safeRedirect($redirect, route('meet.dashboard'));
}
if (! $request->boolean('fallback')) {
@@ -39,7 +43,10 @@ class SsoLoginController extends Controller
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('meet.dashboard'));
app(TeamMemberProvisioner::class)->sync(Auth::user());
$redirect = $this->resolveLanding($identity, Auth::user(), $intended);
return $this->safeRedirect($redirect, route('meet.dashboard'));
}
$verifier = Str::random(64);
@@ -115,7 +122,9 @@ class SsoLoginController extends Controller
$request->session()->regenerate();
$request->session()->forget('sso.attempts');
return $this->finishCallback($request, $intended, null);
app(TeamMemberProvisioner::class)->sync($user);
return $this->finishCallback($request, $intended, null, app(IdentityTeamClient::class));
}
public function failed(Request $request): View
@@ -241,8 +250,12 @@ class SsoLoginController extends Controller
);
}
private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse
{
private function finishCallback(
Request $request,
string $intended,
?string $error = null,
?IdentityTeamClient $identity = null,
): RedirectResponse {
if ($error) {
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
@@ -262,7 +275,21 @@ class SsoLoginController extends Controller
]);
}
return $this->safeRedirect($intended, route('meet.dashboard'));
$user = Auth::user();
$redirect = ($user instanceof User && $identity)
? $this->resolveLanding($identity, $user, $intended)
: $intended;
return $this->safeRedirect($redirect, route('meet.dashboard'));
}
private function resolveLanding(IdentityTeamClient $identity, User $user, string $intended): string
{
try {
return $identity->postAuthRedirect($user->ownerRef(), $intended);
} catch (\Throwable) {
return $intended;
}
}
private function safeReturnUrl(string $url): string
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Member;
use App\Models\Recording;
use App\Models\Room;
use App\Services\Afia\AfiaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AiController extends Controller
{
use ScopesToAccount;
public function chat(Request $request, AfiaService $afia): JsonResponse
{
$validated = $request->validate([
'message' => ['required', 'string', 'max:2000'],
'history' => ['nullable', 'array', 'max:20'],
'history.*.role' => ['nullable', 'string'],
'history.*.text' => ['nullable', 'string'],
]);
if (! $afia->enabled()) {
return response()->json(['message' => 'Afia is not available right now.'], 503);
}
try {
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context($request));
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
}
return response()->json(['reply' => $reply]);
}
/** @return array<string, mixed> */
private function context(Request $request): array
{
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
return [
'signed_in' => 'yes',
'organization' => $organization->name,
'meetings_total' => Room::owned($owner)->where('organization_id', $organization->id)->count(),
'meetings_upcoming' => Room::owned($owner)
->where('organization_id', $organization->id)
->where('status', 'scheduled')
->where('starts_at', '>=', now())
->count(),
'recordings_total' => Recording::owned($owner)
->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id))
->count(),
'team_members' => Member::owned($owner)->where('organization_id', $organization->id)->count(),
];
}
}
+26 -8
View File
@@ -10,6 +10,7 @@ use App\Services\Meet\SessionService;
use App\Services\Meet\WebinarService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class JoinController extends Controller
@@ -50,7 +51,9 @@ class JoinController extends Controller
{
$request->validate(['passcode' => ['required', 'string']]);
abort_unless($room->passcode === $request->input('passcode'), 422);
if ($room->passcode !== $request->input('passcode')) {
return back()->withErrors(['passcode' => 'Incorrect passcode.']);
}
$request->session()->put("meet.passcode.{$room->uuid}", true);
@@ -63,13 +66,24 @@ class JoinController extends Controller
return redirect()->route('meet.join', $room);
}
$validated = $request->validate([
'display_name' => ['required_without:auth', 'string', 'max:100'],
$rules = [
'email' => ['nullable', 'email'],
]);
];
if ($request->user()) {
$rules['display_name'] = ['nullable', 'string', 'max:100'];
} else {
$rules['display_name'] = ['required', 'string', 'max:100'];
}
try {
$validated = $request->validate($rules);
} catch (ValidationException $e) {
throw $e->redirectTo(route('meet.join', $room));
}
$user = $request->user();
$displayName = $user?->name ?? $validated['display_name'];
$displayName = $user?->name ?? ($validated['display_name'] ?? 'Guest');
$email = $user?->email ?? ($validated['email'] ?? null);
[$allowed, $reason] = $this->security->canJoin($room, $user, $email ?? $displayName.'@guest.local');
@@ -86,12 +100,16 @@ class JoinController extends Controller
} elseif ($room->setting('join_before_host') || ($user && $user->ownerRef() === $room->host_user_ref)) {
if (! $room->activeSession() && $user && $user->ownerRef() === $room->host_user_ref) {
$session = $this->sessions->start($room, $user);
} elseif (! $room->activeSession()) {
return back()->withErrors(['join' => 'Meeting has not started yet. Please wait for the host.']);
} else {
abort_unless($room->activeSession(), 422, 'Meeting has not started yet.');
$session = $room->activeSession();
}
} else {
abort_unless($room->activeSession(), 422, 'Meeting has not started yet. Please wait for the host.');
if (! $room->activeSession()) {
return back()->withErrors(['join' => 'Meeting has not started yet. Please wait for the host.']);
}
$session = $room->activeSession();
}
@@ -116,7 +134,7 @@ class JoinController extends Controller
app(InvitationService::class)->markAcceptedOnJoin($room, $user, $email);
$request->session()->put("meet.participant.{$session->uuid}", $participant->uuid);
$this->sessions->rememberParticipantSession($request, $participant, $session);
return redirect()->route('meet.room', $session);
}
+25 -5
View File
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Member;
use App\Services\Identity\IdentityTeamClient;
use App\Services\Meet\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -51,22 +52,41 @@ class MemberController extends Controller
]);
}
public function store(Request $request): RedirectResponse
public function store(Request $request, IdentityTeamClient $identity): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'user_ref' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('meet.roles')))],
'branch_id' => ['nullable', 'integer', 'exists:meet_branches,id'],
]);
$email = strtolower(trim($validated['email']));
if ($email === strtolower((string) $request->user()->email)) {
return back()->withErrors(['email' => 'You cannot invite yourself.']);
}
$identity->inviteAppMember(
$owner,
$email,
['meet'],
[
'meet' => [
'organization_id' => $organization->id,
'role' => $validated['role'],
'branch_id' => $validated['branch_id'] ?? null,
],
],
);
$member = Member::updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $validated['user_ref'],
'user_ref' => $email,
],
[
'owner_ref' => $owner,
@@ -75,9 +95,9 @@ class MemberController extends Controller
],
);
AuditLogger::record($owner, 'member.created', $organization->id, $owner, Member::class, $member->id);
AuditLogger::record($owner, 'member.invited', $organization->id, $owner, Member::class, $member->id);
return redirect()->route('meet.members.index')->with('success', 'Member saved.');
return redirect()->route('meet.members.index')->with('success', 'Invitation sent to '.$email.'.');
}
public function destroy(Request $request, Member $member): RedirectResponse
+15 -1
View File
@@ -172,7 +172,11 @@ class RoomController extends Controller
{
$this->authorizeAbility($request, 'meetings.host');
$this->authorizeOwner($request, $room);
abort_if($room->status === 'cancelled', 422, 'Meeting was cancelled.');
if ($room->status === 'cancelled') {
return redirect()->route('meet.rooms.show', $room)
->withErrors(['start' => 'Meeting was cancelled.']);
}
if ($room->isTownHall() && $room->setting('green_room')) {
$session = app(\App\Services\Meet\TownHallService::class)->startGreenRoom($room, $request->user());
@@ -180,6 +184,11 @@ class RoomController extends Controller
$session = $this->sessions->start($room, $request->user());
}
$participant = $this->sessions->joinedParticipantForUser($session, $request->user());
abort_unless($participant, 500, 'Unable to join as host.');
$this->sessions->rememberParticipantSession($request, $participant, $session);
return redirect()->route('meet.room', $session);
}
@@ -204,6 +213,11 @@ class RoomController extends Controller
$session = $this->sessions->start($room, $request->user());
$participant = $this->sessions->joinedParticipantForUser($session, $request->user());
abort_unless($participant, 500, 'Unable to join as host.');
$this->sessions->rememberParticipantSession($request, $participant, $session);
return redirect()->route('meet.room', $session);
}
@@ -4,7 +4,11 @@ namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Meet\AuditLogger;
use App\Services\Meet\MeetPermissions;
use App\Services\Meet\SecurityPolicyService;
use App\Support\OrganizationBranding;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -13,6 +17,71 @@ class SettingsController extends Controller
{
use ScopesToAccount;
public function edit(Request $request): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(MeetPermissions::class)->can($this->member($request), 'settings.manage');
$branchCount = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->count();
return view('meet.settings.edit', [
'organization' => $organization,
'canManage' => $canManage,
'branchCount' => $branchCount,
'isAdmin' => app(MeetPermissions::class)->isAdmin($this->member($request)),
'orgTypes' => [
'business' => 'Business',
'healthcare' => 'Healthcare',
'education' => 'Education',
'nonprofit' => 'Nonprofit',
],
]);
}
public function update(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'timezone' => ['required', 'timezone'],
'org_type' => ['required', 'string', 'in:business,healthcare,education,nonprofit'],
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
'remove_logo' => ['nullable', 'boolean'],
]);
$settings = $organization->settings ?? [];
$settings['onboarded'] = true;
$settings['org_type'] = $validated['org_type'];
$logoPath = $organization->logo_path;
if ($request->boolean('remove_logo')) {
OrganizationBranding::deleteStoredLogo($organization);
$logoPath = null;
}
if ($request->hasFile('logo')) {
$logoPath = OrganizationBranding::storeLogo($organization, $request->file('logo'));
}
$organization->update([
'name' => $validated['name'],
'timezone' => $validated['timezone'],
'logo_path' => $logoPath,
'settings' => $settings,
]);
AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id);
return back()->with('success', 'Settings saved.');
}
public function security(Request $request): View
{
$this->authorizeAbility($request, 'meetings.manage');
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace App\Services\Afia;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class AfiaService
{
public function enabled(): bool
{
if (! (bool) config('afia.enabled', true)) {
return false;
}
return $this->hasLocalKey() || $this->hasPlatformRelay();
}
/**
* @param array<int, array{role: string, text: string}> $history
* @param array<string, mixed> $context
*/
public function chat(string $message, array $history, array $context): string
{
if (! $this->enabled()) {
throw new RuntimeException('Afia is not configured.');
}
if ($this->hasLocalKey()) {
return $this->chatLocally($message, $history, $context);
}
return $this->chatViaPlatform($message, $history, $context);
}
/**
* @param array<int, array{role: string, text: string}> $history
* @param array<string, mixed> $context
*/
private function chatLocally(string $message, array $history, array $context): string
{
$provider = (string) config('afia.provider', 'openai');
$model = (string) config('afia.model', 'gpt-4o-mini');
$apiKey = (string) config('afia.api_key');
$messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]];
foreach (array_slice($history, -8) as $turn) {
$role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user';
$text = trim((string) ($turn['text'] ?? ''));
if ($text !== '') {
$messages[] = ['role' => $role, 'content' => $text];
}
}
$messages[] = ['role' => 'user', 'content' => $message];
return $provider === 'anthropic'
? $this->viaAnthropic($model, $apiKey, $messages)
: $this->viaOpenAi($model, $apiKey, $messages);
}
/**
* @param array<int, array{role: string, text: string}> $history
* @param array<string, mixed> $context
*/
private function chatViaPlatform(string $message, array $history, array $context): string
{
$base = rtrim((string) config('afia.platform_api_url', ''), '/');
$token = (string) config('afia.platform_api_key', '');
$res = Http::withToken($token)->acceptJson()->timeout(50)->post($base.'/afia/chat', [
'product' => (string) config('afia.product', 'meet'),
'message' => $message,
'history' => $history,
'system_prompt' => $this->systemPrompt($context),
]);
if ($res->status() === 503) {
throw new RuntimeException('Afia is not configured on the platform.');
}
if ($res->failed()) {
throw new RuntimeException('Platform Afia relay failed: '.$res->status());
}
$reply = trim((string) $res->json('reply', ''));
if ($reply === '') {
throw new RuntimeException('Platform Afia relay returned an empty response.');
}
return $reply;
}
private function hasLocalKey(): bool
{
return (string) config('afia.api_key', '') !== '';
}
private function hasPlatformRelay(): bool
{
return rtrim((string) config('afia.platform_api_url', ''), '/') !== ''
&& (string) config('afia.platform_api_key', '') !== '';
}
private function viaOpenAi(string $model, string $apiKey, array $messages): string
{
$res = Http::withToken($apiKey)->acceptJson()->timeout(45)
->post('https://api.openai.com/v1/chat/completions', [
'model' => $model,
'temperature' => 0.3,
'max_tokens' => 600,
'messages' => $messages,
]);
if ($res->failed()) {
throw new RuntimeException('OpenAI request failed: '.$res->status());
}
return trim((string) $res->json('choices.0.message.content', ''));
}
private function viaAnthropic(string $model, string $apiKey, array $messages): string
{
$system = $messages[0]['content'] ?? '';
$turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system'));
$res = Http::withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [
'model' => $model,
'max_tokens' => 600,
'system' => $system,
'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns),
]);
if ($res->failed()) {
throw new RuntimeException('Anthropic request failed: '.$res->status());
}
return trim((string) $res->json('content.0.text', ''));
}
/** @param array<string, mixed> $context */
private function systemPrompt(array $context): string
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return <<<PROMPT
You are Afia, the assistant inside Ladill Meet (meet.ladill.com).
Help staff schedule and run video meetings, manage rooms, recordings, channels, and team access. Be concise and actionable.
Where things live:
- Dashboard: upcoming meetings and quick actions.
- Meetings: create instant, scheduled, personal, or webinar rooms; send invitations.
- Recordings & summaries: review past sessions and AI summaries.
- Channels & messages: team chat inside the organization.
- Admin: security policy, calendar sync, webhooks, contacts, and team members.
- Settings (bottom of sidebar): organization name, timezone, and logo.
Rules:
- Only answer questions about Ladill Meet.
- Never invent meeting counts or participant names use the context below or say where to check.
Current user context:
{$ctx}
PROMPT;
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Services\Identity;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class IdentityTeamClient
{
/**
* @param list<string> $apps
* @param array<string, mixed> $metadata
*
* @return array<string, mixed>
*/
public function inviteAppMember(
string $ownerPublicId,
string $email,
array $apps,
array $metadata = [],
string $role = 'member',
): array {
$response = $this->request('post', '/identity/team/invite', [
'owner' => $ownerPublicId,
'email' => strtolower(trim($email)),
'apps' => $apps,
'role' => $role,
'metadata' => $metadata,
]);
return (array) $response->json('data', []);
}
public function postAuthRedirect(string $userPublicId, string $intendedUrl): string
{
$response = $this->request('get', '/identity/team/post-auth-redirect', [
'user' => $userPublicId,
'redirect' => $intendedUrl,
]);
return (string) $response->json('data.url', $intendedUrl);
}
/** @return list<array<string, mixed>> */
public function teamAccess(string $userPublicId): array
{
$response = $this->request('get', '/identity/team/access', [
'user' => $userPublicId,
]);
return (array) $response->json('data', []);
}
private function request(string $method, string $path, array $query = []): Response
{
$url = rtrim((string) config('identity.api_url'), '/').$path;
$key = config('identity.api_key');
if ($url === '' || ! is_string($key) || $key === '') {
throw new RuntimeException('Identity API is not configured.');
}
$response = Http::withToken($key)
->timeout(10)
->{$method}($url, $query);
if (! $response->successful()) {
throw new RuntimeException($response->json('message') ?: 'Identity API request failed.');
}
return $response;
}
}
+2 -2
View File
@@ -12,10 +12,10 @@ class MeetPermissions
'admin' => ['*'],
'moderator' => [
'dashboard.view', 'meetings.view', 'meetings.create', 'meetings.manage',
'meetings.host', 'chat.moderate', 'reports.view',
'meetings.host', 'chat.moderate', 'reports.view', 'settings.view',
],
'member' => [
'dashboard.view', 'meetings.view', 'meetings.create', 'meetings.host',
'dashboard.view', 'meetings.view', 'meetings.create', 'meetings.host', 'settings.view',
],
'guest' => [
'meetings.view',
+15
View File
@@ -7,6 +7,7 @@ use App\Models\Room;
use App\Models\Session;
use App\Models\User;
use App\Services\Meet\Media\MediaProviderInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class SessionService
@@ -135,6 +136,20 @@ class SessionService
return $participant;
}
public function rememberParticipantSession(Request $request, Participant $participant, ?Session $session = null): void
{
$session ??= $participant->session;
$request->session()->put("meet.participant.{$session->uuid}", $participant->uuid);
}
public function joinedParticipantForUser(Session $session, User $user): ?Participant
{
return $session->participants()
->where('user_ref', $user->ownerRef())
->whereIn('status', ['invited', 'waiting', 'joined'])
->first();
}
public function leaveParticipant(Participant $participant): Participant
{
$participant->update([
@@ -0,0 +1,56 @@
<?php
namespace App\Services\Meet;
use App\Models\Member;
use App\Models\User;
use App\Services\Identity\IdentityTeamClient;
class TeamMemberProvisioner
{
public function __construct(
protected IdentityTeamClient $identity,
) {}
public function sync(User $user): void
{
Member::query()
->where('user_ref', strtolower((string) $user->email))
->update(['user_ref' => $user->ownerRef()]);
try {
$access = $this->identity->teamAccess($user->ownerRef());
} catch (\Throwable) {
return;
}
foreach ($access as $grant) {
if (($grant['self'] ?? false) || ($grant['full_access'] ?? false)) {
continue;
}
$apps = (array) ($grant['apps'] ?? []);
if (! in_array('meet', $apps, true)) {
continue;
}
$appMeta = (array) data_get($grant, 'metadata.meet', []);
$organizationId = (int) ($appMeta['organization_id'] ?? 0);
if ($organizationId <= 0) {
continue;
}
Member::updateOrCreate(
[
'organization_id' => $organizationId,
'user_ref' => $user->ownerRef(),
],
[
'owner_ref' => (string) ($grant['account'] ?? $user->ownerRef()),
'role' => (string) ($appMeta['role'] ?? 'member'),
'branch_id' => $appMeta['branch_id'] ?? null,
],
);
}
}
}
+2 -2
View File
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Storage;
class OrganizationBranding
{
public const DEFAULT_LOGO = 'images/logo/ladillcare-logo.svg';
public const DEFAULT_LOGO = 'images/logo/ladillmeet-logo.svg';
public static function logoUrl(Organization $organization): string
{
@@ -38,7 +38,7 @@ class OrganizationBranding
{
self::deleteStoredLogo($organization);
return $file->store('care/organizations/'.$organization->id, 'public');
return $file->store('meet/organizations/'.$organization->id, 'public');
}
public static function deleteStoredLogo(Organization $organization): void
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'product' => env('AFIA_PRODUCT', 'meet'),
'enabled' => (bool) env('AFIA_ENABLED', true),
'provider' => env('AFIA_PROVIDER', 'openai'),
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
'api_key' => env('AFIA_API_KEY', env('OPENAI_API_KEY')),
'platform_api_url' => env('AFIA_PLATFORM_API_URL', env('IDENTITY_API_URL', 'https://ladill.com/api')),
'platform_api_key' => env('AFIA_PLATFORM_API_KEY', env('IDENTITY_API_KEY_MEET')),
];
@@ -56,5 +56,6 @@
])
@endauth
@include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')])
@include('partials.afia')
</body>
</html>
@@ -1,16 +1,16 @@
<x-app-layout title="Add member">
<div class="mx-auto max-w-lg">
<h1 class="text-xl font-semibold text-slate-900">Add team member</h1>
<h1 class="text-xl font-semibold text-slate-900">Invite team member</h1>
<form method="POST" action="{{ route('meet.members.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700">User public ID</label>
<input type="text" name="user_ref" value="{{ old('user_ref') }}" required
class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm"
placeholder="UUID from Ladill account">
<p class="mt-1 text-xs text-slate-500">The member's Ladill public_id (UUID).</p>
<label class="block text-sm font-medium text-slate-700">Email address</label>
<input type="email" name="email" value="{{ old('email') }}" required
class="mt-1 w-full rounded-lg border-slate-300 text-sm"
placeholder="colleague@company.com">
<p class="mt-1 text-xs text-slate-500">They will receive an email to accept and join your Meet organization.</p>
</div>
<div>
@@ -32,7 +32,7 @@
</select>
</div>
<button type="submit" class="btn-primary">Save member</button>
<button type="submit" class="btn-primary">Send invitation</button>
</form>
</div>
</x-app-layout>
@@ -7,12 +7,17 @@
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr><th class="px-4 py-3">User ID</th><th class="px-4 py-3">Role</th><th class="px-4 py-3">Branch</th><th class="px-4 py-3"></th></tr>
<tr><th class="px-4 py-3">Member</th><th class="px-4 py-3">Role</th><th class="px-4 py-3">Branch</th><th class="px-4 py-3"></th></tr>
</thead>
<tbody class="divide-y divide-slate-50">
@foreach ($members as $member)
@php
$display = str_contains($member->user_ref, '@')
? $member->user_ref
: (\App\Models\User::where('public_id', $member->user_ref)->value('email') ?? $member->user_ref);
@endphp
<tr>
<td class="px-4 py-3 font-mono text-xs">{{ $member->user_ref }}</td>
<td class="px-4 py-3 text-sm">{{ $display }}</td>
<td class="px-4 py-3">{{ $roles[$member->role] ?? $member->role }}</td>
<td class="px-4 py-3">{{ $member->branch?->name ?? 'All branches' }}</td>
<td class="px-4 py-3 text-right">
+6
View File
@@ -13,6 +13,12 @@
<h1 class="text-xl font-semibold">{{ $room->title }}</h1>
<p class="mt-2 text-sm text-slate-400">Ready to join?</p>
@if ($errors->any())
<div class="mt-4 rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{{ $errors->first() }}
</div>
@endif
<form method="POST" action="{{ route('meet.join.enter', $room) }}" class="mt-6 space-y-4">
@csrf
@guest
@@ -13,6 +13,12 @@
@endif
</div>
@if ($errors->any())
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $errors->first() }}
</div>
@endif
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-medium text-slate-700">Join link</h2>
<div class="mt-2 flex gap-2">
@@ -0,0 +1,56 @@
<x-app-layout title="Settings">
<div class="mx-auto max-w-2xl">
<h1 class="text-xl font-semibold text-slate-900">Organization settings</h1>
<form method="POST" action="{{ route('meet.settings.update') }}" enctype="multipart/form-data" class="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
@csrf @method('PUT')
<div>
<label class="block text-sm font-medium text-slate-700">Organization name</label>
<input type="text" name="name" value="{{ old('name', $organization->name) }}" @disabled(! $canManage) required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Organization type</label>
<select name="org_type" @disabled(! $canManage) class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($orgTypes as $value => $label)
<option value="{{ $value }}" @selected(old('org_type', data_get($organization->settings, 'org_type', 'business')) === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Timezone</label>
<select name="timezone" @disabled(! $canManage) class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach (timezone_identifiers_list() as $tz)
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
@endforeach
</select>
</div>
@if ($canManage)
<div>
<label class="block text-sm font-medium text-slate-700">Logo</label>
<input type="file" name="logo" accept="image/png,image/jpeg,image/webp,image/svg+xml" class="mt-2 block w-full text-sm">
@if (\App\Support\OrganizationBranding::hasCustomLogo($organization))
<label class="mt-2 flex items-center gap-2 text-sm"><input type="checkbox" name="remove_logo" value="1"> Remove current logo</label>
@endif
</div>
<button type="submit" class="btn-primary">Save settings</button>
@endif
</form>
@if ($isAdmin ?? false)
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold text-slate-900">Advanced</h2>
<ul class="mt-3 space-y-2 text-sm">
<li><a href="{{ route('meet.settings.security') }}" class="text-indigo-600 hover:text-indigo-800">Security policy</a></li>
<li><a href="{{ route('meet.settings.calendar') }}" class="text-indigo-600 hover:text-indigo-800">Calendar connections</a></li>
<li><a href="{{ route('meet.settings.webhooks') }}" class="text-indigo-600 hover:text-indigo-800">Webhooks</a></li>
</ul>
</div>
@endif
<p class="mt-4 text-sm text-slate-500">{{ $branchCount }} branch(es) configured.</p>
</div>
</x-app-layout>
+99
View File
@@ -0,0 +1,99 @@
@php
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about meetings, rooms, recordings, channels, team members, or settings…";
$afiaSuggestions = [
'How do I schedule a meeting?',
'How do I start an instant meeting?',
'Where are my recordings?',
'How do I invite team members?',
];
@endphp
{{-- Afia Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
<div x-data="afia({
chatUrl: '{{ route('meet.ai.chat') }}',
csrf: '{{ csrf_token() }}',
greeting: @js($afiaGreeting),
suggestions: @js($afiaSuggestions),
})" @afia-open.window="open = true; $nextTick(() => $refs.input && $refs.input.focus())">
<div x-show="open" x-cloak @click="close()" class="fixed inset-0 z-50 bg-slate-900/20"></div>
<section x-show="open" x-cloak
x-transition:enter="transition transform ease-out duration-200"
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transition transform ease-in duration-150"
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
<div class="flex items-center gap-2.5">
<span class="relative flex h-8 w-8 shrink-0 items-center justify-center">
<svg viewBox="0 0 36 36" class="h-8 w-8" aria-hidden="true">
<defs>
<radialGradient id="afia-orb-meet" cx="40%" cy="35%" r="60%">
<stop offset="0%" stop-color="#a5b4fc"/>
<stop offset="50%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#3730a3"/>
</radialGradient>
</defs>
<circle cx="18" cy="18" r="14" fill="url(#afia-orb-meet)">
<animate attributeName="r" values="14;14.8;14" dur="3s" repeatCount="indefinite"/>
</circle>
</svg>
<span class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-400"></span>
</span>
<div class="leading-tight">
<p class="text-sm font-semibold text-slate-900">Afia</p>
<p class="text-[11px] text-slate-400">Meet assistant</p>
</div>
</div>
<div class="flex items-center gap-1">
<a href="{{ ladill_account_url('ai') }}"
class="rounded-lg px-2 py-1 text-[11px] font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-700"
title="AI usage history on your Ladill account">History</a>
<button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 py-4" x-ref="scroll">
<div class="space-y-3">
<template x-for="(m, i) in messages" :key="i">
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
<div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
:class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'"
x-html="m.text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
</div>
</template>
<div x-show="loading" class="flex justify-start">
<div class="rounded-2xl rounded-bl-md bg-slate-100 px-4 py-3">
<div class="flex gap-1">
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:0ms"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:150ms"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:300ms"></span>
</div>
</div>
</div>
</div>
<div x-show="messages.length <= 1 && suggestions.length" class="mt-4 space-y-2">
<template x-for="s in suggestions" :key="s">
<button @click="useSuggestion(s)" :disabled="loading"
class="flex w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-left text-[13px] font-medium text-slate-700 transition hover:border-indigo-200 hover:bg-indigo-50 disabled:opacity-50">
<span class="text-indigo-400"></span><span x-text="s"></span>
</button>
</template>
</div>
</div>
<div class="border-t border-slate-100 px-4 pb-4 pt-3">
<form @submit.prevent="send()" class="flex items-end gap-2">
<input type="text" x-ref="input" x-model="input" :disabled="loading" placeholder="Message Afia…"
class="flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
<button type="submit" :disabled="loading || !input.trim()"
class="btn-fab shrink-0 disabled:opacity-40">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18"/></svg>
</button>
</form>
<p class="mt-2 text-center text-[10px] text-slate-400">Afia can make mistakes verify important details.</p>
</div>
</section>
</div>
+13 -1
View File
@@ -46,7 +46,7 @@
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />'];
}
$settingsActive = false;
$settingsActive = request()->routeIs('meet.settings*');
@endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@@ -69,4 +69,16 @@
</div>
@endif
</nav>
<div class="shrink-0 border-t border-slate-200 px-3 py-3">
@if ($permissions->can($member, 'settings.view'))
<a href="{{ route('meet.settings') }}"
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $settingsActive ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ $settingsActive ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.281Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<span>Settings</span>
</a>
@endif
</div>
</div>
+5
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\Meet\AdminController;
use App\Http\Controllers\Meet\AiController;
use App\Http\Controllers\Meet\CalendarController;
use App\Http\Controllers\Meet\ChannelController;
use App\Http\Controllers\Meet\ContactGroupController;
@@ -57,6 +58,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/onboarding', [OnboardingController::class, 'store'])->name('meet.onboarding.store');
Route::middleware(['meet.setup'])->group(function () {
Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('meet.ai.chat');
Route::get('/dashboard', [DashboardController::class, 'index'])->name('meet.dashboard');
Route::get('/meetings/instant', [RoomController::class, 'instant'])->name('meet.instant');
@@ -125,6 +128,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/settings/security', [SettingsController::class, 'security'])->name('meet.settings.security');
Route::put('/settings/security', [SettingsController::class, 'updateSecurity'])->name('meet.settings.security.update');
Route::get('/settings', [SettingsController::class, 'edit'])->name('meet.settings');
Route::put('/settings', [SettingsController::class, 'update'])->name('meet.settings.update');
Route::get('/room/{session}', [MeetingRoomController::class, 'show'])->name('meet.room');
Route::post('/room/{session}/end', [MeetingRoomController::class, 'end'])->name('meet.room.end');
+69
View File
@@ -78,6 +78,75 @@ class MeetWebTest extends TestCase
]);
}
public function test_instant_meeting_opens_room(): void
{
$response = $this->actingAs($this->user)
->get('/meetings/instant');
$response->assertRedirect();
$session = Session::where('owner_ref', $this->user->public_id)->latest()->first();
$this->assertNotNull($session);
$this->assertTrue($session->isLive());
$this->actingAs($this->user)
->get('/room/'.$session->uuid)
->assertOk()
->assertSee($session->room->title);
}
public function test_host_can_start_scheduled_meeting_into_room(): void
{
$room = $this->createRoom();
$this->actingAs($this->user)
->post('/meetings/'.$room->uuid.'/start')
->assertRedirect();
$session = $room->fresh()->activeSession();
$this->assertNotNull($session);
$this->actingAs($this->user)
->get('/room/'.$session->uuid)
->assertOk();
}
public function test_guest_cannot_join_before_host_starts(): void
{
$room = $this->createRoom([
'settings' => array_merge(config('meet.default_settings'), [
'join_before_host' => false,
]),
]);
$this->post('/r/'.$room->uuid.'/enter', [
'display_name' => 'Guest User',
])
->assertRedirect()
->assertSessionHasErrors('join');
}
public function test_authenticated_user_can_join_live_meeting_without_display_name(): void
{
$room = $this->createRoom();
$session = Session::create([
'owner_ref' => $this->user->public_id,
'room_id' => $room->id,
'media_room_name' => 'room-'.$room->uuid,
'status' => 'live',
'started_at' => now(),
]);
$room->update(['status' => 'live']);
$this->actingAs($this->user)
->post('/r/'.$room->uuid.'/enter')
->assertRedirect(route('meet.room', $session));
$this->actingAs($this->user)
->get('/room/'.$session->uuid)
->assertOk();
}
public function test_room_has_join_url(): void
{
$room = Room::create([