feat: improvements on invites

This commit is contained in:
Paulo Castellano 2026-01-20 16:53:54 -03:00
parent 9ffb3bb98e
commit 1196551a26
63 changed files with 1076 additions and 2375 deletions

View file

@ -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;
});
}
}

View file

@ -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'];
}
}

View file

@ -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();
}
}

View file

@ -1,9 +0,0 @@
<?php
namespace App\Enums\WorkspaceInvite;
enum Status: string
{
case Pending = 'pending';
case Accepted = 'accepted';
}

View 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');
}
}

View 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('/');
}
}

View file

@ -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');
}
}

View file

@ -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'),
]);
}
}

View 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)]);
}
}

View 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)]);
}
}

View 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');
}
}

View 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');
}
}

View file

@ -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'),
]);
}
}

View file

@ -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);

View file

@ -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;

View 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());
}
}

View file

@ -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 [];
}
}

View file

@ -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);
}
}

View file

@ -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');
}
}

View file

@ -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),
],
);
}

View file

@ -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();
}
/**

View file

@ -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',
];
/**

View file

@ -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();
}
}

View file

@ -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);
});
}
}

View file

@ -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']);

View file

@ -2,6 +2,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
];

View file

@ -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
View file

@ -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",

View file

@ -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
]),
],
];

View file

@ -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(),
]);
}
}

View file

@ -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']);
});
}

View file

@ -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">

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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,
};
};

View file

@ -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)

View file

@ -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(),

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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)"

View file

@ -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>

View file

@ -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
View 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');
});

View file

@ -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');
});

View file

@ -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';

View 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();
});

View file

@ -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');
});

View file

@ -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'));
});

View file

@ -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');
});
});

View file

@ -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();
});

View file

@ -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')
);
});

View file

@ -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();
});

View file

@ -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));

View file

@ -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]);

View file

@ -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 () {

View file

@ -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');
});

View file

@ -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');
});

View file

@ -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();