feat: improvements on invites
This commit is contained in:
parent
9ffb3bb98e
commit
1196551a26
63 changed files with 1076 additions and 2375 deletions
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use App\Enums\User\Setup;
|
||||
use App\Models\Language;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => $this->nameRules(),
|
||||
'email' => $this->emailRules(),
|
||||
'password' => ['required', 'string', Password::default()],
|
||||
])->validate();
|
||||
|
||||
return DB::transaction(function () use ($input) {
|
||||
$defaultLanguage = Language::where('code', 'en-US')->first();
|
||||
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
'setup' => Setup::Role,
|
||||
'language_id' => $defaultLanguage?->id,
|
||||
]);
|
||||
|
||||
// Create default workspace for new user
|
||||
$workspace = $user->workspaces()->create([
|
||||
'name' => 'My Workspace',
|
||||
'timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
// Add user as owner member
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
|
||||
// Set as current workspace
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return $user;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => $input['password'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums\WorkspaceInvite;
|
||||
|
||||
enum Status: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Accepted = 'accepted';
|
||||
}
|
||||
97
app/Http/Controllers/Auth/AcceptInviteController.php
Normal file
97
app/Http/Controllers/Auth/AcceptInviteController.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\WorkspaceInvite;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AcceptInviteController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the invite view.
|
||||
*/
|
||||
public function show(WorkspaceInvite $invite): Response
|
||||
{
|
||||
$invite->load('workspace');
|
||||
|
||||
return Inertia::render('auth/AcceptInvite', [
|
||||
'invite' => [
|
||||
'id' => $invite->id,
|
||||
'email' => $invite->email,
|
||||
'role' => [
|
||||
'value' => $invite->role->value,
|
||||
'label' => $invite->role->label(),
|
||||
],
|
||||
'workspace' => [
|
||||
'id' => $invite->workspace->id,
|
||||
'name' => $invite->workspace->name,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the invite.
|
||||
*/
|
||||
public function accept(Request $request, WorkspaceInvite $invite): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Verify the invite is for this user
|
||||
if ($invite->email !== $user->email) {
|
||||
session()->flash('flash.banner', 'This invite is for a different email address.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('calendar');
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
if ($invite->workspace->hasMember($user)) {
|
||||
$invite->delete();
|
||||
|
||||
session()->flash('flash.banner', 'You are already a member of this workspace.');
|
||||
session()->flash('flash.bannerStyle', 'info');
|
||||
|
||||
return redirect()->route('calendar');
|
||||
}
|
||||
|
||||
// Accept the invite
|
||||
$workspaceId = $invite->workspace_id;
|
||||
$invite->accept($user);
|
||||
$user->update(['current_workspace_id' => $workspaceId]);
|
||||
|
||||
session()->flash('flash.banner', 'Welcome! You are now a member of the workspace.');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('calendar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline the invite.
|
||||
*/
|
||||
public function decline(Request $request, WorkspaceInvite $invite): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Verify the invite is for this user
|
||||
if ($invite->email !== $user->email) {
|
||||
session()->flash('flash.banner', 'This invite is for a different email address.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('calendar');
|
||||
}
|
||||
|
||||
$invite->delete();
|
||||
|
||||
session()->flash('flash.banner', 'Invite declined.');
|
||||
session()->flash('flash.bannerStyle', 'info');
|
||||
|
||||
return redirect()->route('calendar');
|
||||
}
|
||||
}
|
||||
61
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
61
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/Login', [
|
||||
'canResetPassword' => true,
|
||||
'canRegister' => true,
|
||||
'status' => session('status'),
|
||||
'email' => $request->query('email'),
|
||||
'redirect' => $request->query('redirect'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
// Check for redirect param
|
||||
if ($redirect = $request->input('redirect')) {
|
||||
return redirect($redirect);
|
||||
}
|
||||
|
||||
return redirect()->intended(route('calendar'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('calendar'));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|Response
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('calendar'))
|
||||
: Inertia::render('auth/VerifyEmail', [
|
||||
'status' => session('status'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
59
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/ResetPassword', [
|
||||
'email' => $request->email,
|
||||
'token' => $request->route('token'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function ($user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
44
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('auth/ForgotPassword', [
|
||||
'status' => session('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
87
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
87
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Language;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/Register', [
|
||||
'email' => $request->query('email'),
|
||||
'redirect' => $request->query('redirect'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Check if registering via invite link (redirect contains /invites/)
|
||||
$isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/');
|
||||
|
||||
$user = DB::transaction(function () use ($request, $isInviteRegistration) {
|
||||
$defaultLanguage = Language::where('code', 'en-US')->first();
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => $request->password,
|
||||
'setup' => $isInviteRegistration ? Setup::Completed : Setup::Role,
|
||||
'language_id' => $defaultLanguage?->id,
|
||||
'email_verified_at' => $isInviteRegistration ? now() : null,
|
||||
]);
|
||||
|
||||
// Create default workspace for new user
|
||||
$workspace = Workspace::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $user->name."'s Workspace",
|
||||
'timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
// Add user as owner member
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
|
||||
// Set as current workspace
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return $user;
|
||||
});
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
// Check for redirect param
|
||||
if ($redirect = $request->input('redirect')) {
|
||||
return redirect($redirect);
|
||||
}
|
||||
|
||||
return redirect()->route('onboarding.step1');
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
29
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('calendar').'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('calendar').'?verified=1');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
|
||||
use Illuminate\Routing\Controllers\HasMiddleware;
|
||||
use Illuminate\Routing\Controllers\Middleware;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
class TwoFactorAuthenticationController extends Controller implements HasMiddleware
|
||||
{
|
||||
/**
|
||||
* Get the middleware that should be assigned to the controller.
|
||||
*/
|
||||
public static function middleware(): array
|
||||
{
|
||||
return Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')
|
||||
? [new Middleware('password.confirm', only: ['show'])]
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the user's two-factor authentication settings page.
|
||||
*/
|
||||
public function show(TwoFactorAuthenticationRequest $request): Response
|
||||
{
|
||||
$request->ensureStateIsValid();
|
||||
|
||||
return Inertia::render('settings/TwoFactor', [
|
||||
'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(),
|
||||
'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -59,11 +59,15 @@ public function store(StoreWorkspaceRequest $request): RedirectResponse
|
|||
->with('message', 'Subscribe to create more workspaces.');
|
||||
}
|
||||
|
||||
$workspace = $user->workspaces()->create([
|
||||
$workspace = Workspace::create([
|
||||
'user_id' => $user->id,
|
||||
...$request->validated(),
|
||||
'timezone' => config('app.timezone', 'UTC'),
|
||||
]);
|
||||
|
||||
// Add user as owner member
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
|
||||
// Set as current workspace
|
||||
$user->switchWorkspace($workspace);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
|
|
@ -27,7 +29,6 @@ public function index(Request $request): Response|RedirectResponse
|
|||
return Inertia::render('settings/Members', [
|
||||
'workspace' => $workspace,
|
||||
'invites' => $workspace->invites()
|
||||
->with('inviter')
|
||||
->latest()
|
||||
->get(),
|
||||
'members' => $workspace->members()
|
||||
|
|
@ -66,12 +67,11 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse
|
|||
|
||||
$existingInvite = $workspace->invites()
|
||||
->where('email', $request->email)
|
||||
->pending()
|
||||
->first();
|
||||
|
||||
if ($existingInvite) {
|
||||
return back()->withErrors([
|
||||
'email' => 'A pending invite already exists for this email.',
|
||||
'email' => 'An invite already exists for this email.',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +82,6 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse
|
|||
}
|
||||
|
||||
$invite = $workspace->invites()->create([
|
||||
'invited_by' => $request->user()->id,
|
||||
'email' => $request->email,
|
||||
'role' => $request->role ?? WorkspaceRole::Member,
|
||||
]);
|
||||
|
|
@ -117,88 +116,6 @@ public function destroy(Request $request, WorkspaceInvite $invite): RedirectResp
|
|||
return back();
|
||||
}
|
||||
|
||||
public function show(Request $request, string $token): Response|RedirectResponse
|
||||
{
|
||||
$invite = WorkspaceInvite::where('token', $token)
|
||||
->with(['workspace', 'inviter'])
|
||||
->firstOrFail();
|
||||
|
||||
if (! $invite->isPending()) {
|
||||
session()->flash('flash.banner', 'This invite is no longer valid.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Store token in session for after login/register
|
||||
session(['pending_invite_token' => $token]);
|
||||
|
||||
return Inertia::render('invites/Accept', [
|
||||
'invite' => [
|
||||
'id' => $invite->id,
|
||||
'token' => $invite->token,
|
||||
'email' => $invite->email,
|
||||
'role' => [
|
||||
'value' => $invite->role->value,
|
||||
'label' => $invite->role->label(),
|
||||
],
|
||||
'workspace' => [
|
||||
'id' => $invite->workspace->id,
|
||||
'name' => $invite->workspace->name,
|
||||
],
|
||||
'inviter' => [
|
||||
'id' => $invite->inviter->id,
|
||||
'name' => $invite->inviter->name,
|
||||
'email' => $invite->inviter->email,
|
||||
],
|
||||
],
|
||||
'isAuthenticated' => (bool) $user,
|
||||
'userEmail' => $user?->email,
|
||||
]);
|
||||
}
|
||||
|
||||
public function accept(Request $request, string $token): RedirectResponse
|
||||
{
|
||||
$invite = WorkspaceInvite::where('token', $token)->firstOrFail();
|
||||
|
||||
if (! $invite->isPending()) {
|
||||
session()->flash('flash.banner', 'This invite is no longer valid.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
session(['pending_invite_token' => $token]);
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Clear the pending token
|
||||
session()->forget('pending_invite_token');
|
||||
|
||||
if ($invite->workspace->hasMember($user)) {
|
||||
$user->switchWorkspace($invite->workspace);
|
||||
|
||||
session()->flash('flash.banner', 'You are already a member of this workspace.');
|
||||
session()->flash('flash.bannerStyle', 'info');
|
||||
|
||||
return redirect()->route('calendar');
|
||||
}
|
||||
|
||||
$invite->accept($user);
|
||||
$user->switchWorkspace($invite->workspace);
|
||||
|
||||
session()->flash('flash.banner', 'You are now a member of the workspace!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('calendar');
|
||||
}
|
||||
|
||||
public function removeMember(Request $request, string $userId): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
|
|
|||
87
app/Http/Requests/Auth/LoginRequest.php
Normal file
87
app/Http/Requests/Auth/LoginRequest.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Fortify\InteractsWithTwoFactorState;
|
||||
|
||||
class TwoFactorAuthenticationRequest extends FormRequest
|
||||
{
|
||||
use InteractsWithTwoFactorState;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return Features::enabled(Features::twoFactorAuthentication());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LoginResponse implements LoginResponseContract
|
||||
{
|
||||
public function toResponse($request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Check for pending invite token
|
||||
if ($token = session('pending_invite_token')) {
|
||||
return redirect()->route('invites.show', $token);
|
||||
}
|
||||
|
||||
// Determine redirect based on setup status
|
||||
$redirect = match ($user->setup) {
|
||||
Setup::Completed => route('calendar'),
|
||||
Setup::Role => route('onboarding.step1'),
|
||||
Setup::Connections => route('onboarding.step2'),
|
||||
Setup::Subscription => route('onboarding.step2'),
|
||||
default => route('onboarding.step1'),
|
||||
};
|
||||
|
||||
return $request->wantsJson()
|
||||
? response()->json(['two_factor' => false])
|
||||
: redirect()->intended($redirect);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RegisterResponse implements RegisterResponseContract
|
||||
{
|
||||
public function toResponse($request): Response
|
||||
{
|
||||
// Check for pending invite token
|
||||
if ($token = session('pending_invite_token')) {
|
||||
return redirect()->route('invites.show', $token);
|
||||
}
|
||||
|
||||
return $request->wantsJson()
|
||||
? response()->json(['two_factor' => false])
|
||||
: redirect()->route('onboarding.step1');
|
||||
}
|
||||
}
|
||||
|
|
@ -31,9 +31,9 @@ public function content(): Content
|
|||
view: 'mail.workspace-invite',
|
||||
with: [
|
||||
'title' => "You've been invited to join {$this->invite->workspace->name}",
|
||||
'previewText' => "{$this->invite->inviter->name} has invited you to collaborate on TryPost.",
|
||||
'previewText' => "You've been invited to join {$this->invite->workspace->name}",
|
||||
'invite' => $this->invite,
|
||||
'url' => route('invites.show', $this->invite->token),
|
||||
'url' => route('invites.show', $this->invite->id),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,24 +5,15 @@
|
|||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
trait HasWorkspace
|
||||
{
|
||||
/**
|
||||
* Get workspaces owned by this user.
|
||||
* Get all workspaces the user belongs to (as owner or member).
|
||||
*/
|
||||
public function workspaces(): HasMany
|
||||
public function workspaces(): BelongsToMany
|
||||
{
|
||||
return $this->hasMany(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspaces where the user is a member (not owner).
|
||||
*/
|
||||
public function memberWorkspaces(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Workspace::class)
|
||||
return $this->belongsToMany(Workspace::class, 'user_workspace')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
|
@ -48,8 +39,7 @@ public function switchWorkspace(Workspace $workspace): void
|
|||
*/
|
||||
public function belongsToWorkspace(Workspace $workspace): bool
|
||||
{
|
||||
return $this->workspaces()->where('id', $workspace->id)->exists()
|
||||
|| $this->memberWorkspaces()->where('workspaces.id', $workspace->id)->exists();
|
||||
return $this->workspaces()->where('workspaces.id', $workspace->id)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -57,7 +47,7 @@ public function belongsToWorkspace(Workspace $workspace): bool
|
|||
*/
|
||||
public function ownedWorkspacesCount(): int
|
||||
{
|
||||
return $this->workspaces()->count();
|
||||
return $this->workspaces()->wherePivot('role', 'owner')->count();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,12 +13,11 @@
|
|||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use Billable, HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable, TwoFactorAuthenticatable;
|
||||
use Billable, HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
@ -33,6 +32,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
'persona',
|
||||
'current_workspace_id',
|
||||
'language_id',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,33 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Enums\WorkspaceInvite\Status as InviteStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WorkspaceInvite extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WorkspaceInviteFactory> */
|
||||
use HasFactory, HasUuids, Notifiable;
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'invited_by',
|
||||
'email',
|
||||
'token',
|
||||
'role',
|
||||
'status',
|
||||
'accepted_at',
|
||||
'workspace_id',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -37,54 +31,20 @@ protected function casts(): array
|
|||
{
|
||||
return [
|
||||
'role' => Role::class,
|
||||
'status' => InviteStatus::class,
|
||||
'accepted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (WorkspaceInvite $invite) {
|
||||
if (empty($invite->token)) {
|
||||
$invite->token = Str::random(64);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function inviter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by');
|
||||
}
|
||||
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', InviteStatus::Pending);
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === InviteStatus::Pending;
|
||||
}
|
||||
|
||||
public function accept(User $user): void
|
||||
{
|
||||
$this->workspace->members()->attach($user->id, [
|
||||
'role' => $this->role,
|
||||
'role' => $this->role->value,
|
||||
]);
|
||||
|
||||
$this->update([
|
||||
'status' => InviteStatus::Accepted,
|
||||
'accepted_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function routeNotificationForMail(): string
|
||||
{
|
||||
return $this->email;
|
||||
$this->delete();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Http\Responses\LoginResponse;
|
||||
use App\Http\Responses\RegisterResponse;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
|
||||
$this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureActions();
|
||||
$this->configureViews();
|
||||
$this->configureRateLimiting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify actions.
|
||||
*/
|
||||
private function configureActions(): void
|
||||
{
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify views.
|
||||
*/
|
||||
private function configureViews(): void
|
||||
{
|
||||
Fortify::loginView(fn (Request $request) => Inertia::render('auth/Login', [
|
||||
'canResetPassword' => Features::enabled(Features::resetPasswords()),
|
||||
'canRegister' => Features::enabled(Features::registration()),
|
||||
'status' => $request->session()->get('status'),
|
||||
'email' => $request->query('email'),
|
||||
]));
|
||||
|
||||
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/ResetPassword', [
|
||||
'email' => $request->email,
|
||||
'token' => $request->route('token'),
|
||||
]));
|
||||
|
||||
Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/ForgotPassword', [
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/VerifyEmail', [
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::registerView(fn (Request $request) => Inertia::render('auth/Register', [
|
||||
'email' => $request->query('email'),
|
||||
]));
|
||||
|
||||
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/TwoFactorChallenge'));
|
||||
|
||||
Fortify::confirmPasswordView(fn () => Inertia::render('auth/ConfirmPassword'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rate limiting.
|
||||
*/
|
||||
private function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
|
|
@ -14,6 +15,10 @@
|
|||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
health: '/up',
|
||||
then: function () {
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/auth.php'));
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,5 @@
|
|||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
"php": "^8.2",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/cashier": "^16.2",
|
||||
"laravel/fortify": "^1.30",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/horizon": "^5.42",
|
||||
"laravel/nightwatch": "^1.22",
|
||||
|
|
|
|||
222
composer.lock
generated
222
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a8cb995fdd4ce6be2997e5befaa457cc",
|
||||
"content-hash": "a97a5767f8bf1ecc41931942e582fc22",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
@ -157,61 +157,6 @@
|
|||
},
|
||||
"time": "2026-01-14T19:13:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "v3.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Bacon/BaconQrCode.git",
|
||||
"reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563",
|
||||
"reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dasprid/enum": "^1.0.3",
|
||||
"ext-iconv": "*",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phly/keep-a-changelog": "^2.12",
|
||||
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
|
||||
"spatie/phpunit-snapshot-assertions": "^5.1.5",
|
||||
"spatie/pixelmatch-php": "^1.2.0",
|
||||
"squizlabs/php_codesniffer": "^3.9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "to generate QR code images"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BaconQrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||
"support": {
|
||||
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3"
|
||||
},
|
||||
"time": "2025-11-19T17:15:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.1",
|
||||
|
|
@ -471,56 +416,6 @@
|
|||
],
|
||||
"time": "2025-01-03T16:18:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dasprid/enum",
|
||||
"version": "1.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DASPRiD/Enum.git",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1 <9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"DASPRiD\\Enum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP 7.1 enum implementation",
|
||||
"keywords": [
|
||||
"enum",
|
||||
"map"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||
},
|
||||
"time": "2025-09-16T12:23:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dflydev/dot-access-data",
|
||||
"version": "v3.0.3",
|
||||
|
|
@ -1706,69 +1601,6 @@
|
|||
},
|
||||
"time": "2026-01-06T16:30:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/fortify",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/fortify.git",
|
||||
"reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/fortify/zipball/e0666dabeec0b6428678af1d51f436dcfb24e3a9",
|
||||
"reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"ext-json": "*",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||
"php": "^8.1",
|
||||
"pragmarx/google2fa": "^9.0",
|
||||
"symfony/console": "^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"orchestra/testbench": "^8.36|^9.15|^10.8",
|
||||
"phpstan/phpstan": "^1.10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Fortify\\FortifyServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Fortify\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Backend controllers and scaffolding for Laravel authentication.",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/fortify/issues",
|
||||
"source": "https://github.com/laravel/fortify"
|
||||
},
|
||||
"time": "2025-12-15T14:48:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v12.47.0",
|
||||
|
|
@ -4433,58 +4265,6 @@
|
|||
],
|
||||
"time": "2025-03-19T16:30:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pragmarx/google2fa",
|
||||
"version": "v9.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/antonioribeiro/google2fa.git",
|
||||
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf",
|
||||
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
|
||||
"php": "^7.1|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.9",
|
||||
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PragmaRX\\Google2FA\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Antonio Carlos Ribeiro",
|
||||
"email": "acr@antoniocarlosribeiro.com",
|
||||
"role": "Creator & Designer"
|
||||
}
|
||||
],
|
||||
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
|
||||
"keywords": [
|
||||
"2fa",
|
||||
"Authentication",
|
||||
"Two Factor Authentication",
|
||||
"google2fa"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/antonioribeiro/google2fa/issues",
|
||||
"source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0"
|
||||
},
|
||||
"time": "2025-09-19T22:51:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "predis/predis",
|
||||
"version": "v3.3.0",
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Fortify will use while
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Password Broker
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which password broker Fortify can use when a user
|
||||
| is resetting their password. This configured value should match one
|
||||
| of your password brokers setup in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => 'users',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Username / Email
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines which model attribute should be considered as your
|
||||
| application's "username" field. Typically, this might be the email
|
||||
| address of the users but you are free to change this value here.
|
||||
|
|
||||
| Out of the box, Fortify expects forgot password and reset password
|
||||
| requests to have a field named 'email'. If the application uses
|
||||
| another name for the field you may define it below as needed.
|
||||
|
|
||||
*/
|
||||
|
||||
'username' => 'email',
|
||||
|
||||
'email' => 'email',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Lowercase Usernames
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines whether usernames should be lowercased before saving
|
||||
| them in the database, as some database system string fields are case
|
||||
| sensitive. You may disable this for your application if necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'lowercase_usernames' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Home Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the path where users will get redirected during
|
||||
| authentication or password reset when the operations are successful
|
||||
| and the user is authenticated. You are free to change this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'home' => '/calendar',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Prefix / Subdomain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which prefix Fortify will assign to all the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| subdomain under which all of the Fortify routes will be available.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => '',
|
||||
|
||||
'domain' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which middleware Fortify will assign to the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| these middleware but typically this provided default is preferred.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Fortify will throttle logins to five requests per minute for
|
||||
| every email and IP address combination. However, if you would like to
|
||||
| specify a custom rate limiter to call then you may specify it here.
|
||||
|
|
||||
*/
|
||||
|
||||
'limiters' => [
|
||||
'login' => 'login',
|
||||
'two-factor' => 'two-factor',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register View Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify if the routes returning views should be disabled as
|
||||
| you may not need them when building your own application. This may be
|
||||
| especially true if you're writing a custom single-page application.
|
||||
|
|
||||
*/
|
||||
|
||||
'views' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Features
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some of the Fortify features are optional. You may disable the features
|
||||
| by removing them from this array. You're free to only remove some of
|
||||
| these features, or you can even remove all of these if you need to.
|
||||
|
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
Features::registration(),
|
||||
Features::resetPasswords(),
|
||||
Features::emailVerification(),
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
// 'window' => 0
|
||||
]),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -3,11 +3,8 @@
|
|||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Enums\WorkspaceInvite\Status as InviteStatus;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkspaceInvite>
|
||||
|
|
@ -22,20 +19,9 @@ class WorkspaceInviteFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'invited_by' => User::factory(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'token' => Str::random(64),
|
||||
'role' => Role::Member,
|
||||
'status' => InviteStatus::Pending,
|
||||
'role' => fake()->randomElement(Role::cases()),
|
||||
'workspace_id' => Workspace::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
public function accepted(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'status' => InviteStatus::Accepted,
|
||||
'accepted_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,17 +13,12 @@ public function up(): void
|
|||
{
|
||||
Schema::create('workspace_invites', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUuid('invited_by')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('email');
|
||||
$table->string('token', 64)->unique();
|
||||
$table->string('role')->default('member');
|
||||
$table->string('status')->default('pending');
|
||||
$table->timestamp('accepted_at')->nullable();
|
||||
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'email']);
|
||||
$table->index(['token', 'status']);
|
||||
$table->unique(['email', 'workspace_id']);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ <h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
|
|||
</h1>
|
||||
|
||||
<p class="m-0 leading-6">
|
||||
<strong>@{{ $invite->inviter->name }}</strong> has invited you to collaborate on the <strong>@{{ $invite->workspace->name }}</strong> workspace.
|
||||
You've been invited to collaborate on the <strong>@{{ $invite->workspace->name }}</strong> workspace.
|
||||
</p>
|
||||
|
||||
<p class="m-0 mt-4 leading-6">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@layer utilities {
|
||||
|
||||
body,
|
||||
|
|
@ -20,111 +19,111 @@ @layer utilities {
|
|||
}
|
||||
|
||||
:root {
|
||||
--background: #faf9f5;
|
||||
--foreground: #3d3929;
|
||||
--card: #faf9f5;
|
||||
--card-foreground: #141413;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #28261b;
|
||||
--primary: #c96442;
|
||||
--background: #fcfcfc;
|
||||
--foreground: #000000;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #000000;
|
||||
--popover: #fcfcfc;
|
||||
--popover-foreground: #000000;
|
||||
--primary: #000000;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #e9e6dc;
|
||||
--secondary-foreground: #535146;
|
||||
--muted: #ede9de;
|
||||
--muted-foreground: #83827d;
|
||||
--accent: #e9e6dc;
|
||||
--accent-foreground: #28261b;
|
||||
--destructive: #141413;
|
||||
--secondary: #ebebeb;
|
||||
--secondary-foreground: #000000;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #525252;
|
||||
--accent: #ebebeb;
|
||||
--accent-foreground: #000000;
|
||||
--destructive: #e54b4f;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #dad9d4;
|
||||
--input: #b4b2a7;
|
||||
--ring: #c96442;
|
||||
--chart-1: #b05730;
|
||||
--chart-2: #9c87f5;
|
||||
--chart-3: #ded8c4;
|
||||
--chart-4: #dbd3f0;
|
||||
--chart-5: #b4552d;
|
||||
--sidebar: #f5f4ee;
|
||||
--sidebar-foreground: #3d3d3a;
|
||||
--sidebar-primary: #c96442;
|
||||
--sidebar-primary-foreground: #fbfbfb;
|
||||
--sidebar-accent: #e9e6dc;
|
||||
--sidebar-accent-foreground: #343434;
|
||||
--border: #e4e4e4;
|
||||
--input: #ebebeb;
|
||||
--ring: #000000;
|
||||
--chart-1: #ffae04;
|
||||
--chart-2: #2d62ef;
|
||||
--chart-3: #a4a4a4;
|
||||
--chart-4: #e4e4e4;
|
||||
--chart-5: #747474;
|
||||
--sidebar: #fcfcfc;
|
||||
--sidebar-foreground: #000000;
|
||||
--sidebar-primary: #000000;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #ebebeb;
|
||||
--sidebar-accent-foreground: #000000;
|
||||
--sidebar-border: #ebebeb;
|
||||
--sidebar-ring: #b5b5b5;
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--sidebar-ring: #000000;
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--radius: 0.5rem;
|
||||
--shadow-x: 0;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 1px;
|
||||
--shadow-blur: 3px;
|
||||
--shadow-blur: 2px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0.1;
|
||||
--shadow-color: oklch(0 0 0);
|
||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
|
||||
--tracking-normal: 0em;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #262624;
|
||||
--foreground: #c3c0b6;
|
||||
--card: #262624;
|
||||
--card-foreground: #faf9f5;
|
||||
--popover: #30302e;
|
||||
--popover-foreground: #e5e5e2;
|
||||
--primary: #d97757;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #faf9f5;
|
||||
--secondary-foreground: #30302e;
|
||||
--muted: #1b1b19;
|
||||
--muted-foreground: #b7b5a9;
|
||||
--accent: #1a1915;
|
||||
--accent-foreground: #f5f4ee;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #3e3e38;
|
||||
--input: #52514a;
|
||||
--ring: #d97757;
|
||||
--chart-1: #b05730;
|
||||
--chart-2: #9c87f5;
|
||||
--chart-3: #1a1915;
|
||||
--chart-4: #2f2b48;
|
||||
--chart-5: #b4552d;
|
||||
--sidebar: #1f1e1d;
|
||||
--sidebar-foreground: #c3c0b6;
|
||||
--sidebar-primary: #343434;
|
||||
--sidebar-primary-foreground: #fbfbfb;
|
||||
--sidebar-accent: #0f0f0e;
|
||||
--sidebar-accent-foreground: #c3c0b6;
|
||||
--sidebar-border: #ebebeb;
|
||||
--sidebar-ring: #b5b5b5;
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--background: #000000;
|
||||
--foreground: #ffffff;
|
||||
--card: #090909;
|
||||
--card-foreground: #ffffff;
|
||||
--popover: #121212;
|
||||
--popover-foreground: #ffffff;
|
||||
--primary: #ffffff;
|
||||
--primary-foreground: #000000;
|
||||
--secondary: #222222;
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #1d1d1d;
|
||||
--muted-foreground: #a4a4a4;
|
||||
--accent: #333333;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ff5b5b;
|
||||
--destructive-foreground: #000000;
|
||||
--border: #242424;
|
||||
--input: #333333;
|
||||
--ring: #a4a4a4;
|
||||
--chart-1: #ffae04;
|
||||
--chart-2: #2671f4;
|
||||
--chart-3: #747474;
|
||||
--chart-4: #525252;
|
||||
--chart-5: #e4e4e4;
|
||||
--sidebar: #121212;
|
||||
--sidebar-foreground: #ffffff;
|
||||
--sidebar-primary: #ffffff;
|
||||
--sidebar-primary-foreground: #000000;
|
||||
--sidebar-accent: #333333;
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: #333333;
|
||||
--sidebar-ring: #a4a4a4;
|
||||
--font-sans: Geist, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--radius: 0.5rem;
|
||||
--shadow-x: 0;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 1px;
|
||||
--shadow-blur: 3px;
|
||||
--shadow-blur: 2px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0.1;
|
||||
--shadow-color: oklch(0 0 0);
|
||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
|
||||
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ function handleLogout() {
|
|||
<img
|
||||
:src="currentWorkspace?.logo.url"
|
||||
:alt="currentWorkspace?.name"
|
||||
class="size-8 rounded-lg object-cover"
|
||||
class="size-8 rounded-full object-cover"
|
||||
/>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ currentWorkspace?.name || 'Select workspace' }}</span>
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
||||
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import AlertError from '@/components/AlertError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||
|
||||
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
|
||||
const isRecoveryCodesVisible = ref<boolean>(false);
|
||||
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
|
||||
|
||||
const toggleRecoveryCodesVisibility = async () => {
|
||||
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
|
||||
await fetchRecoveryCodes();
|
||||
}
|
||||
|
||||
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
|
||||
|
||||
if (isRecoveryCodesVisible.value) {
|
||||
await nextTick();
|
||||
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!recoveryCodesList.value.length) {
|
||||
await fetchRecoveryCodes();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex gap-3">
|
||||
<LockKeyhole class="size-4" />2FA Recovery Codes
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recovery codes let you regain access if you lose your 2FA
|
||||
device. Store them in a secure password manager.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
class="flex flex-col gap-3 select-none sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
|
||||
<component
|
||||
:is="isRecoveryCodesVisible ? EyeOff : Eye"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} Recovery
|
||||
Codes
|
||||
</Button>
|
||||
|
||||
<Form
|
||||
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
|
||||
v-bind="regenerateRecoveryCodes.form()"
|
||||
method="post"
|
||||
:options="{ preserveScroll: true }"
|
||||
@success="fetchRecoveryCodes"
|
||||
#default="{ processing }"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
>
|
||||
<RefreshCw /> Regenerate Codes
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'relative overflow-hidden transition-all duration-300',
|
||||
isRecoveryCodesVisible
|
||||
? 'h-auto opacity-100'
|
||||
: 'h-0 opacity-0',
|
||||
]"
|
||||
>
|
||||
<div v-if="errors?.length" class="mt-6">
|
||||
<AlertError :errors="errors" />
|
||||
</div>
|
||||
<div v-else class="mt-3 space-y-3">
|
||||
<div
|
||||
ref="recoveryCodeSectionRef"
|
||||
class="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm"
|
||||
>
|
||||
<div v-if="!recoveryCodesList.length" class="space-y-2">
|
||||
<div
|
||||
v-for="n in 8"
|
||||
:key="n"
|
||||
class="h-4 animate-pulse rounded bg-muted-foreground/20"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="(code, index) in recoveryCodesList"
|
||||
:key="index"
|
||||
>
|
||||
{{ code }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground select-none">
|
||||
Each recovery code can be used once to access your
|
||||
account and will be removed after use. If you need more,
|
||||
click
|
||||
<span class="font-bold">Regenerate Codes</span> above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import AlertError from '@/components/AlertError.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAppearance } from '@/composables/useAppearance';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import { confirm } from '@/routes/two-factor';
|
||||
|
||||
interface Props {
|
||||
requiresConfirmation: boolean;
|
||||
twoFactorEnabled: boolean;
|
||||
}
|
||||
|
||||
const { resolvedAppearance } = useAppearance();
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const isOpen = defineModel<boolean>('isOpen');
|
||||
|
||||
const { copy, copied } = useClipboard();
|
||||
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
|
||||
useTwoFactorAuth();
|
||||
|
||||
const showVerificationStep = ref(false);
|
||||
const code = ref<string>('');
|
||||
|
||||
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
|
||||
|
||||
const modalConfig = computed<{
|
||||
title: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
}>(() => {
|
||||
if (props.twoFactorEnabled) {
|
||||
return {
|
||||
title: 'Two-Factor Authentication Enabled',
|
||||
description:
|
||||
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
||||
buttonText: 'Close',
|
||||
};
|
||||
}
|
||||
|
||||
if (showVerificationStep.value) {
|
||||
return {
|
||||
title: 'Verify Authentication Code',
|
||||
description: 'Enter the 6-digit code from your authenticator app',
|
||||
buttonText: 'Continue',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Enable Two-Factor Authentication',
|
||||
description:
|
||||
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
|
||||
buttonText: 'Continue',
|
||||
};
|
||||
});
|
||||
|
||||
const handleModalNextStep = () => {
|
||||
if (props.requiresConfirmation) {
|
||||
showVerificationStep.value = true;
|
||||
|
||||
nextTick(() => {
|
||||
pinInputContainerRef.value?.querySelector('input')?.focus();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
clearSetupData();
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const resetModalState = () => {
|
||||
if (props.twoFactorEnabled) {
|
||||
clearSetupData();
|
||||
}
|
||||
|
||||
showVerificationStep.value = false;
|
||||
code.value = '';
|
||||
};
|
||||
|
||||
watch(
|
||||
() => isOpen.value,
|
||||
async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetModalState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!qrCodeSvg.value) {
|
||||
await fetchSetupData();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="isOpen" @update:open="isOpen = $event">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader class="flex items-center justify-center">
|
||||
<div
|
||||
class="mb-3 w-auto rounded-full border border-border bg-card p-0.5 shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-full border border-border bg-muted p-2.5"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 grid grid-cols-5 opacity-50"
|
||||
>
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="`col-${i}`"
|
||||
class="border-r border-border last:border-r-0"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 grid grid-rows-5 opacity-50"
|
||||
>
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="`row-${i}`"
|
||||
class="border-b border-border last:border-b-0"
|
||||
/>
|
||||
</div>
|
||||
<ScanLine
|
||||
class="relative z-20 size-6 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
|
||||
<DialogDescription class="text-center">
|
||||
{{ modalConfig.description }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
class="relative flex w-auto flex-col items-center justify-center space-y-5"
|
||||
>
|
||||
<template v-if="!showVerificationStep">
|
||||
<AlertError v-if="errors?.length" :errors="errors" />
|
||||
<template v-else>
|
||||
<div
|
||||
class="relative mx-auto flex max-w-md items-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border border-border"
|
||||
>
|
||||
<div
|
||||
v-if="!qrCodeSvg"
|
||||
class="absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center bg-background"
|
||||
>
|
||||
<Spinner class="size-6" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="relative z-10 overflow-hidden border p-5"
|
||||
>
|
||||
<div
|
||||
v-html="qrCodeSvg"
|
||||
class="flex aspect-square size-full items-center justify-center"
|
||||
:style="{
|
||||
filter:
|
||||
resolvedAppearance === 'dark'
|
||||
? 'invert(1) brightness(1.5)'
|
||||
: undefined,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center space-x-5">
|
||||
<Button class="w-full" @click="handleModalNextStep">
|
||||
{{ modalConfig.buttonText }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex w-full items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 top-1/2 h-px w-full bg-border"
|
||||
/>
|
||||
<span class="relative bg-card px-2 py-1"
|
||||
>or, enter the code manually</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-full items-center justify-center space-x-2"
|
||||
>
|
||||
<div
|
||||
class="flex w-full items-stretch overflow-hidden rounded-xl border border-border"
|
||||
>
|
||||
<div
|
||||
v-if="!manualSetupKey"
|
||||
class="flex h-full w-full items-center justify-center bg-muted p-3"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<template v-else>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="manualSetupKey"
|
||||
class="h-full w-full bg-background p-3 text-foreground"
|
||||
/>
|
||||
<button
|
||||
@click="copy(manualSetupKey || '')"
|
||||
class="relative block h-auto border-l border-border px-3 hover:bg-muted"
|
||||
>
|
||||
<Check
|
||||
v-if="copied"
|
||||
class="w-4 text-green-500"
|
||||
/>
|
||||
<Copy v-else class="w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Form
|
||||
v-bind="confirm.form()"
|
||||
reset-on-error
|
||||
@finish="code = ''"
|
||||
@success="isOpen = false"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<input type="hidden" name="code" :value="code" />
|
||||
<div
|
||||
ref="pinInputContainerRef"
|
||||
class="relative w-full space-y-3"
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
|
||||
>
|
||||
<InputOTP
|
||||
id="otp"
|
||||
v-model="code"
|
||||
:maxlength="6"
|
||||
:disabled="processing"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
v-for="index in 6"
|
||||
:key="index"
|
||||
:index="index - 1"
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<InputError
|
||||
:message="
|
||||
errors?.confirmTwoFactorAuthentication
|
||||
?.code
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center space-x-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-auto flex-1"
|
||||
@click="showVerificationStep = false"
|
||||
:disabled="processing"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-auto flex-1"
|
||||
:disabled="processing || code.length < 6"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { computed, ref } from 'vue';
|
||||
|
||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||
|
||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
const manualSetupKey = ref<string | null>(null);
|
||||
const qrCodeSvg = ref<string | null>(null);
|
||||
const recoveryCodesList = ref<string[]>([]);
|
||||
|
||||
const hasSetupData = computed<boolean>(
|
||||
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
|
||||
);
|
||||
|
||||
export const useTwoFactorAuth = () => {
|
||||
const fetchQrCode = async (): Promise<void> => {
|
||||
try {
|
||||
const { svg } = await fetchJson<{ svg: string; url: string }>(
|
||||
qrCode.url(),
|
||||
);
|
||||
|
||||
qrCodeSvg.value = svg;
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch QR code');
|
||||
qrCodeSvg.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupKey = async (): Promise<void> => {
|
||||
try {
|
||||
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
|
||||
secretKey.url(),
|
||||
);
|
||||
|
||||
manualSetupKey.value = key;
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch a setup key');
|
||||
manualSetupKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSetupData = (): void => {
|
||||
manualSetupKey.value = null;
|
||||
qrCodeSvg.value = null;
|
||||
clearErrors();
|
||||
};
|
||||
|
||||
const clearErrors = (): void => {
|
||||
errors.value = [];
|
||||
};
|
||||
|
||||
const clearTwoFactorAuthData = (): void => {
|
||||
clearSetupData();
|
||||
clearErrors();
|
||||
recoveryCodesList.value = [];
|
||||
};
|
||||
|
||||
const fetchRecoveryCodes = async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
recoveryCodesList.value = await fetchJson<string[]>(
|
||||
recoveryCodes.url(),
|
||||
);
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch recovery codes');
|
||||
recoveryCodesList.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupData = async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
||||
} catch {
|
||||
qrCodeSvg.value = null;
|
||||
manualSetupKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
recoveryCodesList,
|
||||
errors,
|
||||
hasSetupData,
|
||||
clearSetupData,
|
||||
clearErrors,
|
||||
clearTwoFactorAuthData,
|
||||
fetchQrCode,
|
||||
fetchSetupKey,
|
||||
fetchSetupData,
|
||||
fetchRecoveryCodes,
|
||||
};
|
||||
};
|
||||
|
|
@ -9,11 +9,6 @@ function getUserTimezone(): string {
|
|||
}
|
||||
|
||||
export default {
|
||||
formatDate(date: string | null | undefined) {
|
||||
if (!date) return '-';
|
||||
return dayjs(date).format('DD/MM/YYYY');
|
||||
},
|
||||
|
||||
formatDateTime(date: string) {
|
||||
return dayjs
|
||||
.utc(date)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { members } from '@/routes';
|
|||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { index as billing } from '@/routes/billing';
|
||||
import { edit as editProfile } from '@/routes/profile';
|
||||
import { show } from '@/routes/two-factor';
|
||||
import { edit as editPassword } from '@/routes/user-password';
|
||||
import { settings as workspaceSettings } from '@/routes/workspace';
|
||||
import { type NavItem, type SharedData } from '@/types';
|
||||
|
|
@ -26,10 +25,6 @@ const navItems = computed<NavItem[]>(() => {
|
|||
title: 'Password',
|
||||
href: editPassword(),
|
||||
},
|
||||
{
|
||||
title: 'Two-Factor Auth',
|
||||
href: show(),
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
href: editAppearance(),
|
||||
|
|
|
|||
107
resources/js/pages/auth/AcceptInvite.vue
Normal file
107
resources/js/pages/auth/AcceptInvite.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { login, register } from '@/routes';
|
||||
import { accept, decline } from '@/routes/invites';
|
||||
import { type SharedData } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
invite: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>();
|
||||
|
||||
const page = usePage<SharedData>();
|
||||
const user = computed(() => page.props.auth?.user);
|
||||
const isLoggedIn = computed(() => !!user.value);
|
||||
|
||||
const inviteUrl = computed(() => `/invites/${props.invite.id}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||
|
||||
<Head title="Accept Invite" />
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Link href="/" class="flex flex-col items-center gap-2 font-medium">
|
||||
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-8 w-auto" />
|
||||
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-8 w-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-xl">You've been invited!</CardTitle>
|
||||
<CardDescription>
|
||||
You've been invited to join the
|
||||
<span class="font-medium text-foreground">{{ invite.workspace.name }}</span>
|
||||
workspace.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="rounded-lg bg-muted p-4 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Workspace</span>
|
||||
<span class="font-medium">{{ invite.workspace.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Your role</span>
|
||||
<span class="font-medium">{{ invite.role.label }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Email</span>
|
||||
<span class="font-medium">{{ invite.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User is logged in - show Accept/Decline -->
|
||||
<div v-if="isLoggedIn" class="flex flex-col gap-3">
|
||||
<Button as-child size="lg" class="w-full">
|
||||
<Link :href="accept.url(invite.id)" method="post">
|
||||
Accept Invite
|
||||
</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline" size="lg" class="w-full">
|
||||
<Link :href="decline.url(invite.id)" method="post">
|
||||
Decline Invite
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- User is not logged in - show Login/Register -->
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
Log in or create an account to accept this invite.
|
||||
</p>
|
||||
<Button as-child size="lg" class="w-full">
|
||||
<Link :href="login({ query: { redirect: inviteUrl } })">
|
||||
Log in
|
||||
</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline" size="lg" class="w-full">
|
||||
<Link :href="register({ query: { redirect: inviteUrl } })">
|
||||
Create Account
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -18,6 +18,7 @@ defineProps<{
|
|||
canResetPassword: boolean;
|
||||
canRegister: boolean;
|
||||
email?: string | null;
|
||||
redirect?: string | null;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ defineProps<{
|
|||
v-slot="{ errors, processing }"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<input v-if="redirect" type="hidden" name="redirect" :value="redirect" />
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email address</Label>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { store } from '@/routes/register';
|
|||
|
||||
defineProps<{
|
||||
email?: string | null;
|
||||
redirect?: string | null;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ defineProps<{
|
|||
v-slot="{ errors, processing }"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<input v-if="redirect" type="hidden" name="redirect" :value="redirect" />
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Name</Label>
|
||||
|
|
|
|||
|
|
@ -1,139 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { store } from '@/routes/two-factor/login';
|
||||
|
||||
interface AuthConfigContent {
|
||||
title: string;
|
||||
description: string;
|
||||
toggleText: string;
|
||||
}
|
||||
|
||||
const authConfigContent = computed<AuthConfigContent>(() => {
|
||||
if (showRecoveryInput.value) {
|
||||
return {
|
||||
title: 'Recovery Code',
|
||||
description:
|
||||
'Please confirm access to your account by entering one of your emergency recovery codes.',
|
||||
toggleText: 'login using an authentication code',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Authentication Code',
|
||||
description:
|
||||
'Enter the authentication code provided by your authenticator application.',
|
||||
toggleText: 'login using a recovery code',
|
||||
};
|
||||
});
|
||||
|
||||
const showRecoveryInput = ref<boolean>(false);
|
||||
|
||||
const toggleRecoveryMode = (clearErrors: () => void): void => {
|
||||
showRecoveryInput.value = !showRecoveryInput.value;
|
||||
clearErrors();
|
||||
code.value = '';
|
||||
};
|
||||
|
||||
const code = ref<string>('');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
:title="authConfigContent.title"
|
||||
:description="authConfigContent.description"
|
||||
>
|
||||
<Head title="Two-Factor Authentication" />
|
||||
|
||||
<div class="space-y-6">
|
||||
<template v-if="!showRecoveryInput">
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
class="space-y-4"
|
||||
reset-on-error
|
||||
@error="code = ''"
|
||||
#default="{ errors, processing, clearErrors }"
|
||||
>
|
||||
<input type="hidden" name="code" :value="code" />
|
||||
<div
|
||||
class="flex flex-col items-center justify-center space-y-3 text-center"
|
||||
>
|
||||
<div class="flex w-full items-center justify-center">
|
||||
<InputOTP
|
||||
id="otp"
|
||||
v-model="code"
|
||||
:maxlength="6"
|
||||
:disabled="processing"
|
||||
autofocus
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
v-for="index in 6"
|
||||
:key="index"
|
||||
:index="index - 1"
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
<InputError :message="errors.code" />
|
||||
</div>
|
||||
<Button type="submit" class="w-full" :disabled="processing"
|
||||
>Continue</Button
|
||||
>
|
||||
<div class="text-center text-sm text-muted-foreground">
|
||||
<span>or you can </span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
@click="() => toggleRecoveryMode(clearErrors)"
|
||||
>
|
||||
{{ authConfigContent.toggleText }}
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
class="space-y-4"
|
||||
reset-on-error
|
||||
#default="{ errors, processing, clearErrors }"
|
||||
>
|
||||
<Input
|
||||
name="recovery_code"
|
||||
type="text"
|
||||
placeholder="Enter recovery code"
|
||||
:autofocus="showRecoveryInput"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.recovery_code" />
|
||||
<Button type="submit" class="w-full" :disabled="processing"
|
||||
>Continue</Button
|
||||
>
|
||||
|
||||
<div class="text-center text-sm text-muted-foreground">
|
||||
<span>or you can </span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
@click="() => toggleRecoveryMode(clearErrors)"
|
||||
>
|
||||
{{ authConfigContent.toggleText }}
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { login, register } from '@/routes';
|
||||
|
||||
const props = defineProps<{
|
||||
invite: {
|
||||
id: string;
|
||||
token: string;
|
||||
email: string;
|
||||
role: {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
inviter: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
isAuthenticated: boolean;
|
||||
userEmail?: string;
|
||||
}>();
|
||||
|
||||
const accepting = ref(false);
|
||||
|
||||
const acceptInvite = () => {
|
||||
accepting.value = true;
|
||||
router.post(`/invites/${props.invite.token}/accept`, {}, {
|
||||
onFinish: () => {
|
||||
accepting.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||
<Head title="Accept Invite" />
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Link href="/" class="flex flex-col items-center gap-2 font-medium">
|
||||
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-8 w-auto" />
|
||||
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-8 w-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-xl">You've been invited!</CardTitle>
|
||||
<CardDescription>
|
||||
<span class="font-medium text-foreground">{{ invite.inviter.name }}</span>
|
||||
has invited you to join the
|
||||
<span class="font-medium text-foreground">{{ invite.workspace.name }}</span>
|
||||
workspace.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="rounded-lg bg-muted p-4 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Workspace</span>
|
||||
<span class="font-medium">{{ invite.workspace.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Your role</span>
|
||||
<span class="font-medium">{{ invite.role.label }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Invited by</span>
|
||||
<span class="font-medium">{{ invite.inviter.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated user -->
|
||||
<template v-if="isAuthenticated">
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
You're logged in as <span class="font-medium text-foreground">{{ userEmail }}</span>
|
||||
</p>
|
||||
<Button
|
||||
@click="acceptInvite"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="accepting"
|
||||
>
|
||||
<Spinner v-if="accepting" class="mr-2" />
|
||||
Accept Invite
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Not authenticated -->
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
To accept this invite, please log in or create an account.
|
||||
</p>
|
||||
<div class="grid gap-2">
|
||||
<Button
|
||||
as-child
|
||||
class="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Link :href="login({ query: { email: invite.email } })">
|
||||
Log in to accept
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Link :href="register({ query: { email: invite.email } })">
|
||||
Create an account
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p class="text-center text-xs text-muted-foreground">
|
||||
This invite was sent to <span class="font-medium">{{ invite.email }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -32,8 +32,6 @@ interface Invite {
|
|||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
inviter: { name: string };
|
||||
}
|
||||
|
||||
interface Role {
|
||||
|
|
@ -85,22 +83,6 @@ function handleRemoveMember(memberId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
accepted: 'Accepted',
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
accepted: 'bg-green-100 text-green-800',
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
function getRoleLabel(role: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
owner: 'Owner',
|
||||
|
|
@ -203,17 +185,11 @@ function getRoleIcon(role: string) {
|
|||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium truncate">{{ invite.email }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{{ getRoleLabel(invite.role) }}
|
||||
</Badge>
|
||||
<Badge :class="getStatusColor(invite.status)" class="text-xs">
|
||||
{{ getStatusLabel(invite.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" class="text-xs mt-1">
|
||||
{{ getRoleLabel(invite.role) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
v-if="invite.status === 'pending'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="cancelInvite(invite.id)"
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { ShieldBan, ShieldCheck } from 'lucide-vue-next';
|
||||
import { onUnmounted, ref } from 'vue';
|
||||
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
|
||||
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { disable, enable, show } from '@/routes/two-factor';
|
||||
import { BreadcrumbItem } from '@/types';
|
||||
|
||||
interface Props {
|
||||
requiresConfirmation?: boolean;
|
||||
twoFactorEnabled?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
requiresConfirmation: false,
|
||||
twoFactorEnabled: false,
|
||||
});
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Two-Factor Authentication',
|
||||
href: show.url(),
|
||||
},
|
||||
];
|
||||
|
||||
const { hasSetupData, clearTwoFactorAuthData } = useTwoFactorAuth();
|
||||
const showSetupModal = ref<boolean>(false);
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTwoFactorAuthData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="Two-Factor Authentication" />
|
||||
|
||||
<h1 class="sr-only">Two-Factor Authentication Settings</h1>
|
||||
|
||||
<SettingsLayout>
|
||||
<div class="space-y-6">
|
||||
<HeadingSmall
|
||||
title="Two-Factor Authentication"
|
||||
description="Manage your two-factor authentication settings"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="!twoFactorEnabled"
|
||||
class="flex flex-col items-start justify-start space-y-4"
|
||||
>
|
||||
<Badge variant="destructive">Disabled</Badge>
|
||||
|
||||
<p class="text-muted-foreground">
|
||||
When you enable two-factor authentication, you will be
|
||||
prompted for a secure pin during login. This pin can be
|
||||
retrieved from a TOTP-supported application on your
|
||||
phone.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
v-if="hasSetupData"
|
||||
@click="showSetupModal = true"
|
||||
>
|
||||
<ShieldCheck />Continue Setup
|
||||
</Button>
|
||||
<Form
|
||||
v-else
|
||||
v-bind="enable.form()"
|
||||
@success="showSetupModal = true"
|
||||
#default="{ processing }"
|
||||
>
|
||||
<Button type="submit" :disabled="processing">
|
||||
<ShieldCheck />Enable 2FA</Button
|
||||
></Form
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-start justify-start space-y-4"
|
||||
>
|
||||
<Badge variant="default">Enabled</Badge>
|
||||
|
||||
<p class="text-muted-foreground">
|
||||
With two-factor authentication enabled, you will be
|
||||
prompted for a secure, random pin during login, which
|
||||
you can retrieve from the TOTP-supported application on
|
||||
your phone.
|
||||
</p>
|
||||
|
||||
<TwoFactorRecoveryCodes />
|
||||
|
||||
<div class="relative inline">
|
||||
<Form v-bind="disable.form()" #default="{ processing }">
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
>
|
||||
<ShieldBan />
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TwoFactorSetupModal
|
||||
v-model:isOpen="showSetupModal"
|
||||
:requiresConfirmation="requiresConfirmation"
|
||||
:twoFactorEnabled="twoFactorEnabled"
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
You've been invited!
|
||||
</h1>
|
||||
<p style="margin: 0; line-height: 24px">
|
||||
<strong>{{ $invite->inviter->name }}</strong> has invited you to collaborate on the <strong>{{ $invite->workspace->name }}</strong> workspace.
|
||||
You've been invited to collaborate on the <strong>{{ $invite->workspace->name }}</strong> workspace.
|
||||
</p>
|
||||
<p style="margin: 16px 0 0; line-height: 24px">
|
||||
You've been invited as <strong>{{ $invite->role->label() }}</strong>.
|
||||
|
|
|
|||
46
routes/auth.php
Normal file
46
routes/auth.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Auth\AcceptInviteController;
|
||||
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationPromptController;
|
||||
use App\Http\Controllers\Auth\NewPasswordController;
|
||||
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
||||
use App\Http\Controllers\Auth\RegisteredUserController;
|
||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/register', [RegisteredUserController::class, 'create'])->name('register');
|
||||
Route::post('/register', [RegisteredUserController::class, 'store'])->name('register.store');
|
||||
|
||||
Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login');
|
||||
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->name('login.store');
|
||||
|
||||
Route::get('/forgot-password', [PasswordResetLinkController::class, 'create'])->name('password.request');
|
||||
Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])->name('password.email');
|
||||
|
||||
Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])->name('password.reset');
|
||||
Route::post('/reset-password', [NewPasswordController::class, 'store'])->name('password.store');
|
||||
});
|
||||
|
||||
// Invite routes - accessible by both guests and authenticated users
|
||||
Route::get('/invites/{invite}', [AcceptInviteController::class, 'show'])->name('invites.show');
|
||||
Route::post('/invites/{invite}/accept', [AcceptInviteController::class, 'accept'])->name('invites.accept')->middleware('auth');
|
||||
Route::post('/invites/{invite}/decline', [AcceptInviteController::class, 'decline'])->name('invites.decline')->middleware('auth');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/verify-email', EmailVerificationPromptController::class)->name('verification.notice');
|
||||
|
||||
Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class)
|
||||
->middleware(['signed', 'throttle:6,1'])
|
||||
->name('verification.verify');
|
||||
|
||||
Route::post('/email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
|
||||
->middleware('throttle:6,1')
|
||||
->name('verification.send');
|
||||
|
||||
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout');
|
||||
});
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
use App\Http\Controllers\Settings\PasswordController;
|
||||
use App\Http\Controllers\Settings\ProfileController;
|
||||
use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
||||
|
|
@ -23,7 +22,4 @@
|
|||
Route::get('settings/appearance', function () {
|
||||
return Inertia::render('settings/Appearance');
|
||||
})->name('appearance.edit');
|
||||
|
||||
Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show'])
|
||||
->name('two-factor.show');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -146,10 +146,4 @@
|
|||
Route::get('billing/portal', [BillingController::class, 'portal'])->name('billing.portal');
|
||||
});
|
||||
|
||||
// Invite routes (accessible with or without auth)
|
||||
Route::get('invites/{token}/accept', [WorkspaceInviteController::class, 'show'])
|
||||
->name('invites.show');
|
||||
Route::post('invites/{token}/accept', [WorkspaceInviteController::class, 'accept'])
|
||||
->name('invites.accept');
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
|
|
|||
197
tests/Feature/AcceptInviteControllerTest.php
Normal file
197
tests/Feature/AcceptInviteControllerTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
use App\Models\Language;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceInvite;
|
||||
|
||||
beforeEach(function () {
|
||||
Language::factory()->create(['code' => 'en-US']);
|
||||
$this->owner = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]);
|
||||
});
|
||||
|
||||
test('show invite displays invite details for guest', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'email' => 'newuser@example.com',
|
||||
'role' => WorkspaceRole::Member,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('invites.show', $invite));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('auth/AcceptInvite', false)
|
||||
->has('invite')
|
||||
->where('invite.id', $invite->id)
|
||||
->where('invite.email', 'newuser@example.com')
|
||||
->where('invite.role.value', WorkspaceRole::Member->value)
|
||||
->where('invite.workspace.name', $this->workspace->name)
|
||||
);
|
||||
});
|
||||
|
||||
test('show invite displays invite details for authenticated user', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee@example.com',
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Member,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('invites.show', $invite));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('auth/AcceptInvite', false)
|
||||
->has('invite')
|
||||
);
|
||||
});
|
||||
|
||||
test('show invite returns 404 for non-existent invite', function () {
|
||||
$response = $this->get(route('invites.show', 'non-existent-uuid'));
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('accept invite requires authentication', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('invites.accept', $invite));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('accept invite adds user to workspace', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee@example.com',
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Admin,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('invites.accept', $invite));
|
||||
|
||||
$response->assertRedirect(route('calendar'));
|
||||
|
||||
// User should be member of workspace
|
||||
expect($this->workspace->hasMember($user))->toBeTrue();
|
||||
|
||||
// Invite should be deleted
|
||||
expect(WorkspaceInvite::find($invite->id))->toBeNull();
|
||||
|
||||
// User's current workspace should be updated
|
||||
$user->refresh();
|
||||
expect($user->current_workspace_id)->toBe($this->workspace->id);
|
||||
});
|
||||
|
||||
test('accept invite fails for wrong email', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'different@example.com',
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Member,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('invites.accept', $invite));
|
||||
|
||||
$response->assertRedirect(route('calendar'));
|
||||
$response->assertSessionHas('flash.bannerStyle', 'danger');
|
||||
|
||||
// Invite should NOT be deleted
|
||||
expect(WorkspaceInvite::find($invite->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('accept invite handles already member', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee@example.com',
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$this->workspace->members()->attach($user->id, ['role' => WorkspaceRole::Member->value]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Admin,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('invites.accept', $invite));
|
||||
|
||||
$response->assertRedirect(route('calendar'));
|
||||
$response->assertSessionHas('flash.bannerStyle', 'info');
|
||||
|
||||
// Invite should be deleted
|
||||
expect(WorkspaceInvite::find($invite->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('decline invite requires authentication', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('invites.decline', $invite));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('decline invite deletes the invite', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee@example.com',
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Member,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('invites.decline', $invite));
|
||||
|
||||
$response->assertRedirect(route('calendar'));
|
||||
$response->assertSessionHas('flash.bannerStyle', 'info');
|
||||
|
||||
// Invite should be deleted
|
||||
expect(WorkspaceInvite::find($invite->id))->toBeNull();
|
||||
|
||||
// User should NOT be member of workspace
|
||||
expect($this->workspace->hasMember($user))->toBeFalse();
|
||||
});
|
||||
|
||||
test('decline invite fails for wrong email', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'different@example.com',
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => WorkspaceRole::Member,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('invites.decline', $invite));
|
||||
|
||||
$response->assertRedirect(route('calendar'));
|
||||
$response->assertSessionHas('flash.bannerStyle', 'danger');
|
||||
|
||||
// Invite should NOT be deleted
|
||||
expect(WorkspaceInvite::find($invite->id))->not->toBeNull();
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Laravel\Fortify\Features;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
test('login screen can be rendered', function () {
|
||||
$response = $this->get(route('login'));
|
||||
|
|
@ -19,35 +19,7 @@
|
|||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('onboarding.step1', absolute: false));
|
||||
});
|
||||
|
||||
test('users with two factor enabled are redirected to two factor challenge', function () {
|
||||
if (! Features::canManageTwoFactorAuthentication()) {
|
||||
$this->markTestSkipped('Two-factor authentication is not enabled.');
|
||||
}
|
||||
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => encrypt('test-secret'),
|
||||
'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
|
||||
'two_factor_confirmed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$response = $this->post(route('login'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('two-factor.login'));
|
||||
$response->assertSessionHas('login.id', $user->id);
|
||||
$this->assertGuest();
|
||||
$response->assertRedirect(route('calendar', absolute: false));
|
||||
});
|
||||
|
||||
test('users can not authenticate with invalid password', function () {
|
||||
|
|
@ -67,18 +39,24 @@
|
|||
$response = $this->actingAs($user)->post(route('logout'));
|
||||
|
||||
$this->assertGuest();
|
||||
$response->assertRedirect(route('home'));
|
||||
$response->assertRedirect('/');
|
||||
});
|
||||
|
||||
test('users are rate limited', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
RateLimiter::increment(md5('login'.implode('|', [$user->email, '127.0.0.1'])), amount: 5);
|
||||
$throttleKey = Str::transliterate(Str::lower($user->email).'|127.0.0.1');
|
||||
|
||||
RateLimiter::hit($throttleKey, 60);
|
||||
RateLimiter::hit($throttleKey, 60);
|
||||
RateLimiter::hit($throttleKey, 60);
|
||||
RateLimiter::hit($throttleKey, 60);
|
||||
RateLimiter::hit($throttleKey, 60);
|
||||
|
||||
$response = $this->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertTooManyRequests();
|
||||
$response->assertSessionHasErrors('email');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
test('confirm password screen can be rendered', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('password.confirm'));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('auth/ConfirmPassword')
|
||||
);
|
||||
});
|
||||
|
||||
test('password confirmation requires authentication', function () {
|
||||
$response = $this->get(route('password.confirm'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
$this->post(route('password.email'), ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$response = $this->post(route('password.update'), [
|
||||
$response = $this->post(route('password.store'), [
|
||||
'token' => $notification->token,
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
test('password cannot be reset with invalid token', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->post(route('password.update'), [
|
||||
$response = $this->post(route('password.store'), [
|
||||
'token' => 'invalid-token',
|
||||
'email' => $user->email,
|
||||
'password' => 'newpassword123',
|
||||
|
|
@ -70,4 +70,4 @@
|
|||
]);
|
||||
|
||||
$response->assertSessionHasErrors('email');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,5 +31,30 @@
|
|||
|
||||
expect($user)->not->toBeNull();
|
||||
expect($user->workspaces)->toHaveCount(1);
|
||||
expect($user->workspaces->first()->name)->toBe('My Workspace');
|
||||
expect($user->workspaces->first()->name)->toBe("Test User's Workspace");
|
||||
});
|
||||
|
||||
test('new users do not have verified email by default', function () {
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'Password123!',
|
||||
]);
|
||||
|
||||
$user = User::where('email', 'test@example.com')->first();
|
||||
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('new users registering via invite have verified email automatically', function () {
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'Password123!',
|
||||
'redirect' => '/invites/some-invite-id',
|
||||
]);
|
||||
|
||||
$user = User::where('email', 'test@example.com')->first();
|
||||
|
||||
expect($user->email_verified_at)->not->toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
test('two factor challenge redirects to login when not authenticated', function () {
|
||||
if (! Features::canManageTwoFactorAuthentication()) {
|
||||
$this->markTestSkipped('Two-factor authentication is not enabled.');
|
||||
}
|
||||
|
||||
$response = $this->get(route('two-factor.login'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('two factor challenge can be rendered', function () {
|
||||
if (! Features::canManageTwoFactorAuthentication()) {
|
||||
$this->markTestSkipped('Two-factor authentication is not enabled.');
|
||||
}
|
||||
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => encrypt('test-secret'),
|
||||
'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
|
||||
'two_factor_confirmed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->post(route('login'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->get(route('two-factor.login'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('auth/TwoFactorChallenge')
|
||||
);
|
||||
});
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
test('two factor settings page can be rendered', function () {
|
||||
if (! Features::canManageTwoFactorAuthentication()) {
|
||||
$this->markTestSkipped('Two-factor authentication is not enabled.');
|
||||
}
|
||||
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession(['auth.password_confirmed_at' => time()])
|
||||
->get(route('two-factor.show'))
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('settings/TwoFactor')
|
||||
->where('twoFactorEnabled', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('two factor settings page requires password confirmation when enabled', function () {
|
||||
if (! Features::canManageTwoFactorAuthentication()) {
|
||||
$this->markTestSkipped('Two-factor authentication is not enabled.');
|
||||
}
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('two-factor.show'));
|
||||
|
||||
$response->assertRedirect(route('password.confirm'));
|
||||
});
|
||||
|
||||
test('two factor settings page does not requires password confirmation when disabled', function () {
|
||||
if (! Features::canManageTwoFactorAuthentication()) {
|
||||
$this->markTestSkipped('Two-factor authentication is not enabled.');
|
||||
}
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => false,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('two-factor.show'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('settings/TwoFactor')
|
||||
);
|
||||
});
|
||||
|
||||
test('two factor settings page returns forbidden response when two factor is disabled', function () {
|
||||
if (! Features::canManageTwoFactorAuthentication()) {
|
||||
$this->markTestSkipped('Two-factor authentication is not enabled.');
|
||||
}
|
||||
|
||||
config(['fortify.features' => []]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession(['auth.password_confirmed_at' => time()])
|
||||
->get(route('two-factor.show'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
|
|
@ -18,7 +19,10 @@
|
|||
});
|
||||
|
||||
test('workspaces index shows all workspaces for user', function () {
|
||||
Workspace::factory()->count(2)->create(['user_id' => $this->user->id]);
|
||||
$workspaces = Workspace::factory()->count(2)->create(['user_id' => $this->user->id]);
|
||||
foreach ($workspaces as $workspace) {
|
||||
$workspace->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
}
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('workspaces.index'));
|
||||
|
||||
|
|
@ -120,6 +124,7 @@
|
|||
|
||||
test('switch workspace changes current workspace', function () {
|
||||
$otherWorkspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$otherWorkspace->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$response = $this->actingAs($this->user)->post(route('workspaces.switch', $otherWorkspace));
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@
|
|||
test('members index shows members and invites', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'invited_by' => $this->user->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('members'));
|
||||
|
|
@ -78,7 +77,6 @@
|
|||
test('store invite fails if invite already exists', function () {
|
||||
WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'invited_by' => $this->user->id,
|
||||
'email' => 'existing@example.com',
|
||||
]);
|
||||
|
||||
|
|
@ -106,7 +104,6 @@
|
|||
test('destroy invite requires authentication', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'invited_by' => $this->user->id,
|
||||
]);
|
||||
|
||||
$response = $this->delete(route('invites.destroy', $invite));
|
||||
|
|
@ -117,7 +114,6 @@
|
|||
test('destroy invite deletes invite', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'invited_by' => $this->user->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->delete(route('invites.destroy', $invite));
|
||||
|
|
@ -137,87 +133,6 @@
|
|||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// Show invite tests
|
||||
test('show invite displays invite details', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'invited_by' => $this->user->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('invites.show', $invite->token));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('invites/Accept', false)
|
||||
->has('invite')
|
||||
);
|
||||
});
|
||||
|
||||
test('show invite redirects for accepted invite', function () {
|
||||
$invite = WorkspaceInvite::factory()->accepted()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'invited_by' => $this->user->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('invites.show', $invite->token));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
// Accept invite tests
|
||||
test('accept invite redirects to login if not authenticated', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'invited_by' => $this->user->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('invites.accept', $invite->token));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('accept invite adds user to workspace', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'invited_by' => $this->user->id,
|
||||
]);
|
||||
|
||||
$newUser = User::factory()->create(['setup' => Setup::Completed]);
|
||||
|
||||
$response = $this->actingAs($newUser)->post(route('invites.accept', $invite->token));
|
||||
|
||||
$response->assertRedirect(route('calendar'));
|
||||
expect($this->workspace->hasMember($newUser))->toBeTrue();
|
||||
});
|
||||
|
||||
test('accept invite redirects for accepted invite', function () {
|
||||
$invite = WorkspaceInvite::factory()->accepted()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'invited_by' => $this->user->id,
|
||||
]);
|
||||
|
||||
$newUser = User::factory()->create(['setup' => Setup::Completed]);
|
||||
|
||||
$response = $this->actingAs($newUser)->post(route('invites.accept', $invite->token));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('accept invite handles already member', function () {
|
||||
$invite = WorkspaceInvite::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'invited_by' => $this->user->id,
|
||||
]);
|
||||
|
||||
$member = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
|
||||
|
||||
$response = $this->actingAs($member)->post(route('invites.accept', $invite->token));
|
||||
|
||||
$response->assertRedirect(route('calendar'));
|
||||
$response->assertSessionHas('flash.banner', 'You are already a member of this workspace.');
|
||||
});
|
||||
|
||||
// Remove member tests
|
||||
test('remove member requires authentication', function () {
|
||||
$member = User::factory()->create(['setup' => Setup::Completed]);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\WorkspaceInvite;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceInvite as WorkspaceInviteModel;
|
||||
|
||||
test('workspace invite mail has correct subject', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['name' => 'Test Workspace']);
|
||||
$invite = WorkspaceInviteModel::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'invited_by' => $user->id,
|
||||
]);
|
||||
|
||||
$mail = new WorkspaceInvite($invite);
|
||||
|
|
@ -19,11 +16,9 @@
|
|||
});
|
||||
|
||||
test('workspace invite mail has correct content', function () {
|
||||
$user = User::factory()->create(['name' => 'John Doe']);
|
||||
$workspace = Workspace::factory()->create(['name' => 'My Team']);
|
||||
$invite = WorkspaceInviteModel::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'invited_by' => $user->id,
|
||||
]);
|
||||
|
||||
$mail = new WorkspaceInvite($invite);
|
||||
|
|
@ -31,9 +26,9 @@
|
|||
|
||||
expect($content->view)->toBe('mail.workspace-invite');
|
||||
expect($content->with['title'])->toBe("You've been invited to join My Team");
|
||||
expect($content->with['previewText'])->toContain('John Doe');
|
||||
expect($content->with['previewText'])->toBe("You've been invited to join My Team");
|
||||
expect($content->with['invite'])->toBe($invite);
|
||||
expect($content->with['url'])->toBe(route('invites.show', $invite->token));
|
||||
expect($content->with['url'])->toBe(route('invites.show', $invite->id));
|
||||
});
|
||||
|
||||
test('workspace invite mail has no attachments', function () {
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Http\Responses\LoginResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
test('login redirects to calendar when setup is completed', function () {
|
||||
$user = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$request = Request::create('/login', 'POST');
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = (new LoginResponse)->toResponse($request);
|
||||
|
||||
expect($response->getTargetUrl())->toContain('calendar');
|
||||
});
|
||||
|
||||
test('login redirects to step1 when setup is role', function () {
|
||||
$user = User::factory()->create([
|
||||
'setup' => Setup::Role,
|
||||
]);
|
||||
|
||||
$request = Request::create('/login', 'POST');
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = (new LoginResponse)->toResponse($request);
|
||||
|
||||
expect($response->getTargetUrl())->toContain('onboarding/step1');
|
||||
});
|
||||
|
||||
test('login redirects to step2 when setup is connections', function () {
|
||||
$user = User::factory()->create([
|
||||
'setup' => Setup::Connections,
|
||||
]);
|
||||
|
||||
$request = Request::create('/login', 'POST');
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = (new LoginResponse)->toResponse($request);
|
||||
|
||||
expect($response->getTargetUrl())->toContain('onboarding/step2');
|
||||
});
|
||||
|
||||
test('login redirects to step2 when setup is subscription', function () {
|
||||
$user = User::factory()->create([
|
||||
'setup' => Setup::Subscription,
|
||||
]);
|
||||
|
||||
$request = Request::create('/login', 'POST');
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = (new LoginResponse)->toResponse($request);
|
||||
|
||||
expect($response->getTargetUrl())->toContain('onboarding/step2');
|
||||
});
|
||||
|
||||
test('login redirects to invite when pending invite token exists', function () {
|
||||
$user = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
session(['pending_invite_token' => 'test-token-123']);
|
||||
|
||||
$response = $this->actingAs($user)->post('/login');
|
||||
|
||||
// The test validates the session has the token, and Fortify + LoginResponse should redirect
|
||||
// But since we're testing the Response class directly in other tests, let's verify session works
|
||||
expect(session('pending_invite_token'))->toBe('test-token-123');
|
||||
|
||||
session()->forget('pending_invite_token');
|
||||
});
|
||||
|
||||
test('login returns json response when wantsJson', function () {
|
||||
$user = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
$request = Request::create('/login', 'POST', [], [], [], ['HTTP_ACCEPT' => 'application/json']);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = (new LoginResponse)->toResponse($request);
|
||||
|
||||
expect($response->getContent())->toContain('two_factor');
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Responses\RegisterResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
test('register redirects to onboarding step1', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$request = Request::create('/register', 'POST');
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = (new RegisterResponse)->toResponse($request);
|
||||
|
||||
expect($response->getTargetUrl())->toContain('onboarding/step1');
|
||||
});
|
||||
|
||||
test('register redirects to invite when pending invite token exists', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
session(['pending_invite_token' => 'test-token-456']);
|
||||
|
||||
// Test the session token is stored correctly
|
||||
expect(session('pending_invite_token'))->toBe('test-token-456');
|
||||
|
||||
session()->forget('pending_invite_token');
|
||||
});
|
||||
|
||||
test('register returns json response when wantsJson', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$request = Request::create('/register', 'POST', [], [], [], ['HTTP_ACCEPT' => 'application/json']);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = (new RegisterResponse)->toResponse($request);
|
||||
|
||||
expect($response->getContent())->toContain('two_factor');
|
||||
});
|
||||
|
|
@ -3,23 +3,27 @@
|
|||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
test('user can get owned workspaces', function () {
|
||||
test('user can get workspaces they belong to', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace1 = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace2 = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
// Add user as owner to both workspaces via pivot
|
||||
$workspace1->members()->attach($user->id, ['role' => 'owner']);
|
||||
$workspace2->members()->attach($user->id, ['role' => 'owner']);
|
||||
|
||||
expect($user->workspaces)->toHaveCount(2);
|
||||
expect($user->workspaces->pluck('id')->toArray())->toContain($workspace1->id, $workspace2->id);
|
||||
});
|
||||
|
||||
test('user can get member workspaces', function () {
|
||||
test('user can get workspaces as member', function () {
|
||||
$owner = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
|
||||
$workspace->members()->attach($member->id, ['role' => 'member']);
|
||||
|
||||
expect($member->memberWorkspaces)->toHaveCount(1);
|
||||
expect($member->memberWorkspaces->first()->id)->toBe($workspace->id);
|
||||
expect($member->workspaces)->toHaveCount(1);
|
||||
expect($member->workspaces->first()->id)->toBe($workspace->id);
|
||||
});
|
||||
|
||||
test('user can get current workspace', function () {
|
||||
|
|
@ -44,6 +48,7 @@
|
|||
test('user belongs to owned workspace', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
|
||||
expect($user->belongsToWorkspace($workspace))->toBeTrue();
|
||||
});
|
||||
|
|
@ -67,7 +72,11 @@
|
|||
|
||||
test('user can get owned workspaces count', function () {
|
||||
$user = User::factory()->create();
|
||||
Workspace::factory()->count(3)->create(['user_id' => $user->id]);
|
||||
$workspaces = Workspace::factory()->count(3)->create(['user_id' => $user->id]);
|
||||
|
||||
foreach ($workspaces as $workspace) {
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
}
|
||||
|
||||
expect($user->ownedWorkspacesCount())->toBe(3);
|
||||
});
|
||||
|
|
@ -80,7 +89,8 @@
|
|||
|
||||
test('user without subscription cannot create second workspace', function () {
|
||||
$user = User::factory()->create();
|
||||
Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
|
||||
expect($user->canCreateWorkspace())->toBeFalse();
|
||||
});
|
||||
|
|
@ -95,7 +105,8 @@
|
|||
'quantity' => 3,
|
||||
]);
|
||||
|
||||
Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
|
||||
expect($user->canCreateWorkspace())->toBeTrue();
|
||||
});
|
||||
|
|
@ -110,7 +121,11 @@
|
|||
'quantity' => 2,
|
||||
]);
|
||||
|
||||
Workspace::factory()->count(2)->create(['user_id' => $user->id]);
|
||||
$workspaces = Workspace::factory()->count(2)->create(['user_id' => $user->id]);
|
||||
|
||||
foreach ($workspaces as $workspace) {
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
}
|
||||
|
||||
expect($user->canCreateWorkspace())->toBeFalse();
|
||||
});
|
||||
|
|
@ -133,7 +148,11 @@
|
|||
|
||||
test('sync workspace quantity does nothing without subscription', function () {
|
||||
$user = User::factory()->create();
|
||||
Workspace::factory()->count(2)->create(['user_id' => $user->id]);
|
||||
$workspaces = Workspace::factory()->count(2)->create(['user_id' => $user->id]);
|
||||
|
||||
foreach ($workspaces as $workspace) {
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
}
|
||||
|
||||
$user->syncWorkspaceQuantity();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue