From 123ae68972627f435ddb257a30460302f82fff4f Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Sat, 12 Sep 2026 17:22:31 -0300 Subject: [PATCH] Give invitations their own place in the navigation The "Invite client" button led to a history, which is not what it says. The tabs were a way of housing two things that had nowhere else to live, and now they do: Invitations is a sidebar entry between Custom fields and Groups, and the button goes to the form. That is also the shape every other list in this application already has -- Clients, Groups, Categories, Roles all sit in the sidebar with a "New X" button leading to their own create screen -- so the tabs were the odd one out rather than the pattern. Two URLs, each meaning one thing: /clients/invitations is the history, /clients/invitations/create is the form. Sending now returns to the history, where the invitation just sent is the first row. No badge on the sidebar entry, deliberately, unlike the two queues below it. Account requests and Membership requests count things waiting on somebody here; an outstanding invitation is waiting on the person who was invited. A number there would say "you have three things to do" about three things nobody in this installation can act on. Translations move with it: "History (:count pending)" was the tab label and is gone from all sixteen, and "Invite a client to share files with" comes back -- it was the form's description before the tabs took the heading, and had never been translated because it left the code in the same commit that would have reported it missing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe --- .../Http/Controllers/InvitationController.php | 26 +-- lang/ca.json | 4 +- lang/cs.json | 4 +- lang/de.json | 4 +- lang/es.json | 4 +- lang/fr.json | 4 +- lang/id.json | 4 +- lang/it.json | 4 +- lang/ja.json | 4 +- lang/nl.json | 4 +- lang/pl.json | 4 +- lang/pt_BR.json | 4 +- lang/ru.json | 4 +- lang/sw.json | 4 +- lang/tr.json | 4 +- lang/vi.json | 4 +- lang/zh_CN.json | 4 +- resources/js/components/app-sidebar.tsx | 14 ++ resources/js/pages/clients/invitations.tsx | 165 ++++++++++++++++ resources/js/pages/clients/invite.tsx | 187 +----------------- routes/web.php | 12 +- .../Feature/Clients/ClientInvitationTest.php | 55 +++--- 22 files changed, 270 insertions(+), 253 deletions(-) create mode 100644 resources/js/pages/clients/invitations.tsx diff --git a/app/Modules/Clients/Http/Controllers/InvitationController.php b/app/Modules/Clients/Http/Controllers/InvitationController.php index 04656fcc..cbef5edd 100644 --- a/app/Modules/Clients/Http/Controllers/InvitationController.php +++ b/app/Modules/Clients/Http/Controllers/InvitationController.php @@ -54,7 +54,7 @@ class InvitationController extends Controller Invitation::STATUS_SUPERSEDED, ]; - public function create(Request $request): Response + public function index(Request $request): Response { $validated = $request->validate([ 'status' => ['nullable', 'string', Rule::in(self::FILTERABLE_STATES)], @@ -91,19 +91,10 @@ class InvitationController extends Controller 'state' => $invitation->state(), ]); - return Inertia::render('clients/invite', [ - 'groups' => Group::query()->orderBy('name')->get(['id', 'name']), - // Resolved, not raw — see ClientsController::create()'s note on - // the same prop: this is what will actually happen, and the - // form's own field mirrors this resolution to draw its hint. - 'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(), + return Inertia::render('clients/invitations', [ 'invitations' => $invitations->items(), 'pagination' => Pagination::meta($invitations), 'filters' => ['status' => $status], - // Counted over the whole table rather than the filtered page: - // it is the "anything waiting for me?" number, and it must not - // change because somebody narrowed the list. - 'pending_count' => Invitation::query()->pending()->where('expires_at', '>=', now())->count(), ]); } @@ -120,6 +111,17 @@ class InvitationController extends Controller }; } + public function create(): Response + { + return Inertia::render('clients/invite', [ + 'groups' => Group::query()->orderBy('name')->get(['id', 'name']), + // Resolved, not raw — see ClientsController::create()'s note on + // the same prop: this is what will actually happen, and the + // form's own field mirrors this resolution to draw its hint. + 'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(), + ]); + } + public function store(Request $request): RedirectResponse { $validated = $request->validate([ @@ -159,7 +161,7 @@ class InvitationController extends Controller $this->activity->log(Action::ClientInvited, context: ['email' => $invitation->email]); - return redirect()->route('clients.index')->with('success', __('Invitation sent.')); + return redirect()->route('invitations.index')->with('success', __('Invitation sent.')); } /** diff --git a/lang/ca.json b/lang/ca.json index 7a178517..a9c6727d 100644 --- a/lang/ca.json +++ b/lang/ca.json @@ -2045,7 +2045,6 @@ "Create account": "Crea el compte", "Create your account": "Crea el teu compte", "Group (optional)": "Grup (opcional)", - "History (:count pending)": "Historial (pendents: :count)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Quant de temps continua sent vàlida una invitació enviada per l'equip abans que l'adreça convidada n'hagi de demanar una de nova.", "If that invitation can still be resent, a new one is on its way.": "Si aquesta invitació encara es pot reenviar, ja n'hi ha una de nova en camí.", "Invitation links expire after (hours)": "Les invitacions caduquen al cap de (hores)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Aquesta invitació ja no és vàlida. Demana a qui t'ha convidat que te n'enviï una de nova.", "You've been invited to register": "T'han convidat a registrar-te", "You've been invited to register a client account. The link below will let you set your own password.": "T'han convidat a registrar un compte de client. L'enllaç de sota et permetrà triar la teva pròpia contrasenya.", - "Your account has been created. You will be able to log in once it is approved.": "S'ha creat el teu compte. Podràs iniciar la sessió quan s'aprovi." + "Your account has been created. You will be able to log in once it is approved.": "S'ha creat el teu compte. Podràs iniciar la sessió quan s'aprovi.", + "Invite a client to share files with": "Convida un client amb qui compartir fitxers" } diff --git a/lang/cs.json b/lang/cs.json index a7397d3f..2dce0db7 100644 --- a/lang/cs.json +++ b/lang/cs.json @@ -2045,7 +2045,6 @@ "Create account": "Vytvořit účet", "Create your account": "Vytvořte si účet", "Group (optional)": "Skupina (nepovinné)", - "History (:count pending)": "Historie (čeká: :count)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Jak dlouho zůstává pozvánka odeslaná týmem platná, než si pozvaná adresa musí vyžádat novou.", "If that invitation can still be resent, a new one is on its way.": "Pokud lze tuto pozvánku ještě odeslat znovu, nová už je na cestě.", "Invitation links expire after (hours)": "Odkazy z pozvánek vyprší po (hodinách)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Tato pozvánka už není platná. Požádejte toho, kdo vás pozval, o novou.", "You've been invited to register": "Byli jste pozváni k registraci", "You've been invited to register a client account. The link below will let you set your own password.": "Byli jste pozváni k registraci klientského účtu. Odkaz níže vám umožní zvolit si vlastní heslo.", - "Your account has been created. You will be able to log in once it is approved.": "Váš účet byl vytvořen. Přihlásit se budete moci, jakmile bude schválen." + "Your account has been created. You will be able to log in once it is approved.": "Váš účet byl vytvořen. Přihlásit se budete moci, jakmile bude schválen.", + "Invite a client to share files with": "Pozvěte klienta, se kterým budete sdílet soubory" } diff --git a/lang/de.json b/lang/de.json index b3632c31..3bc0d539 100644 --- a/lang/de.json +++ b/lang/de.json @@ -2045,7 +2045,6 @@ "Create account": "Konto erstellen", "Create your account": "Erstellen Sie Ihr Konto", "Group (optional)": "Gruppe (optional)", - "History (:count pending)": "Verlauf (:count ausstehend)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Wie lange eine vom Team verschickte Einladung gültig bleibt, bevor die eingeladene Adresse eine neue anfordern muss.", "If that invitation can still be resent, a new one is on its way.": "Falls diese Einladung noch einmal verschickt werden kann, ist eine neue bereits unterwegs.", "Invitation links expire after (hours)": "Einladungslinks laufen ab nach (Stunden)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Diese Einladung ist nicht mehr gültig. Bitten Sie die Person, die Sie eingeladen hat, um eine neue.", "You've been invited to register": "Sie wurden zur Registrierung eingeladen", "You've been invited to register a client account. The link below will let you set your own password.": "Sie wurden eingeladen, ein Kundenkonto zu registrieren. Über den Link unten können Sie Ihr eigenes Passwort festlegen.", - "Your account has been created. You will be able to log in once it is approved.": "Ihr Konto wurde erstellt. Sie können sich anmelden, sobald es genehmigt wurde." + "Your account has been created. You will be able to log in once it is approved.": "Ihr Konto wurde erstellt. Sie können sich anmelden, sobald es genehmigt wurde.", + "Invite a client to share files with": "Laden Sie einen Kunden ein, mit dem Sie Dateien teilen" } diff --git a/lang/es.json b/lang/es.json index 86db8dbc..7646f969 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2045,7 +2045,6 @@ "Create account": "Crear cuenta", "Create your account": "Crea tu cuenta", "Group (optional)": "Grupo (opcional)", - "History (:count pending)": "Historial (pendientes: :count)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Cuánto tiempo sigue siendo válida una invitación enviada por el personal antes de que la dirección invitada tenga que pedir otra.", "If that invitation can still be resent, a new one is on its way.": "Si esa invitación todavía se puede reenviar, ya va de camino una nueva.", "Invitation links expire after (hours)": "Las invitaciones caducan al cabo de (horas)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Esta invitación ya no es válida. Pídele a quien te invitó que te envíe otra.", "You've been invited to register": "Te han invitado a registrarte", "You've been invited to register a client account. The link below will let you set your own password.": "Te han invitado a registrar una cuenta de cliente. El enlace de abajo te permitirá elegir tu propia contraseña.", - "Your account has been created. You will be able to log in once it is approved.": "Tu cuenta ha sido creada. Podrás iniciar sesión en cuanto se apruebe." + "Your account has been created. You will be able to log in once it is approved.": "Tu cuenta ha sido creada. Podrás iniciar sesión en cuanto se apruebe.", + "Invite a client to share files with": "Invita a un cliente con quien compartir archivos" } diff --git a/lang/fr.json b/lang/fr.json index f1eed3ea..e2fa495b 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -2045,7 +2045,6 @@ "Create account": "Créer le compte", "Create your account": "Créez votre compte", "Group (optional)": "Groupe (facultatif)", - "History (:count pending)": "Historique (:count en attente)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Durée pendant laquelle une invitation envoyée par l'équipe reste valable, avant que l'adresse invitée doive en demander une nouvelle.", "If that invitation can still be resent, a new one is on its way.": "Si cette invitation peut encore être renvoyée, une nouvelle est déjà en route.", "Invitation links expire after (hours)": "Les invitations expirent au bout de (heures)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Cette invitation n'est plus valable. Demandez à la personne qui vous a invité de vous en envoyer une nouvelle.", "You've been invited to register": "Vous avez été invité à vous inscrire", "You've been invited to register a client account. The link below will let you set your own password.": "Vous avez été invité à créer un compte client. Le lien ci-dessous vous permettra de choisir votre propre mot de passe.", - "Your account has been created. You will be able to log in once it is approved.": "Votre compte a été créé. Vous pourrez vous connecter une fois qu'il aura été approuvé." + "Your account has been created. You will be able to log in once it is approved.": "Votre compte a été créé. Vous pourrez vous connecter une fois qu'il aura été approuvé.", + "Invite a client to share files with": "Invitez un client avec qui partager des fichiers" } diff --git a/lang/id.json b/lang/id.json index 6f8105d3..f8583d90 100644 --- a/lang/id.json +++ b/lang/id.json @@ -2045,7 +2045,6 @@ "Create account": "Buat akun", "Create your account": "Buat akun Anda", "Group (optional)": "Grup (opsional)", - "History (:count pending)": "Riwayat (:count tertunda)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Berapa lama undangan yang dikirim staf tetap berlaku sebelum alamat yang diundang harus meminta yang baru.", "If that invitation can still be resent, a new one is on its way.": "Jika undangan itu masih bisa dikirim ulang, yang baru sedang dalam perjalanan.", "Invitation links expire after (hours)": "Tautan undangan kedaluwarsa setelah (jam)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Undangan ini tidak berlaku lagi. Mintalah orang yang mengundang Anda untuk mengirim yang baru.", "You've been invited to register": "Anda diundang untuk mendaftar", "You've been invited to register a client account. The link below will let you set your own password.": "Anda diundang untuk mendaftarkan akun klien. Tautan di bawah ini memungkinkan Anda menetapkan kata sandi sendiri.", - "Your account has been created. You will be able to log in once it is approved.": "Akun Anda telah dibuat. Anda bisa masuk setelah akun disetujui." + "Your account has been created. You will be able to log in once it is approved.": "Akun Anda telah dibuat. Anda bisa masuk setelah akun disetujui.", + "Invite a client to share files with": "Undang klien untuk berbagi berkas" } diff --git a/lang/it.json b/lang/it.json index f7a2b431..470d06c1 100644 --- a/lang/it.json +++ b/lang/it.json @@ -2045,7 +2045,6 @@ "Create account": "Crea account", "Create your account": "Crea il tuo account", "Group (optional)": "Gruppo (facoltativo)", - "History (:count pending)": "Cronologia (:count in sospeso)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Per quanto tempo resta valido un invito inviato dallo staff prima che l'indirizzo invitato debba chiederne uno nuovo.", "If that invitation can still be resent, a new one is on its way.": "Se quell'invito può ancora essere inviato di nuovo, ne sta arrivando uno nuovo.", "Invitation links expire after (hours)": "Gli inviti scadono dopo (ore)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Questo invito non è più valido. Chiedi a chi ti ha invitato di inviartene uno nuovo.", "You've been invited to register": "Sei stato invitato a registrarti", "You've been invited to register a client account. The link below will let you set your own password.": "Sei stato invitato a registrare un account cliente. Il link qui sotto ti permetterà di scegliere la tua password.", - "Your account has been created. You will be able to log in once it is approved.": "Il tuo account è stato creato. Potrai accedere non appena verrà approvato." + "Your account has been created. You will be able to log in once it is approved.": "Il tuo account è stato creato. Potrai accedere non appena verrà approvato.", + "Invite a client to share files with": "Invita un cliente con cui condividere i file" } diff --git a/lang/ja.json b/lang/ja.json index a9b5a03a..f54f5841 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -2045,7 +2045,6 @@ "Create account": "アカウントを作成", "Create your account": "アカウントの作成", "Group (optional)": "グループ (任意)", - "History (:count pending)": "履歴 (保留中 :count 件)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "スタッフが送った招待が有効な期間。これを過ぎると、招待された相手は新しい招待をリクエストする必要があります。", "If that invitation can still be resent, a new one is on its way.": "その招待をまだ再送できる場合は、新しい招待を送信しました。", "Invitation links expire after (hours)": "招待リンクの有効期間 (時間)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "この招待は無効になりました。招待した相手に新しい招待を送ってもらってください。", "You've been invited to register": "登録のご招待", "You've been invited to register a client account. The link below will let you set your own password.": "クライアントアカウントの登録に招待されました。下のリンクからご自身のパスワードを設定できます。", - "Your account has been created. You will be able to log in once it is approved.": "アカウントを作成しました。承認されるとログインできます。" + "Your account has been created. You will be able to log in once it is approved.": "アカウントを作成しました。承認されるとログインできます。", + "Invite a client to share files with": "ファイルを共有するクライアントを招待します" } diff --git a/lang/nl.json b/lang/nl.json index 34f5b84b..6e114da0 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -2045,7 +2045,6 @@ "Create account": "Account aanmaken", "Create your account": "Maak je account aan", "Group (optional)": "Groep (optioneel)", - "History (:count pending)": "Geschiedenis (:count in behandeling)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Hoe lang een door het team verstuurde uitnodiging geldig blijft voordat het uitgenodigde adres een nieuwe moet aanvragen.", "If that invitation can still be resent, a new one is on its way.": "Als die uitnodiging nog opnieuw verstuurd kan worden, is er al een nieuwe onderweg.", "Invitation links expire after (hours)": "Uitnodigingslinks verlopen na (uren)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Deze uitnodiging is niet meer geldig. Vraag degene die je heeft uitgenodigd om een nieuwe.", "You've been invited to register": "Je bent uitgenodigd om je te registreren", "You've been invited to register a client account. The link below will let you set your own password.": "Je bent uitgenodigd om een klantaccount te registreren. Via de onderstaande link kun je je eigen wachtwoord instellen.", - "Your account has been created. You will be able to log in once it is approved.": "Je account is aangemaakt. Je kunt inloggen zodra het is goedgekeurd." + "Your account has been created. You will be able to log in once it is approved.": "Je account is aangemaakt. Je kunt inloggen zodra het is goedgekeurd.", + "Invite a client to share files with": "Nodig een klant uit om bestanden mee te delen" } diff --git a/lang/pl.json b/lang/pl.json index c805a428..b3923439 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -2045,7 +2045,6 @@ "Create account": "Utwórz konto", "Create your account": "Utwórz swoje konto", "Group (optional)": "Grupa (opcjonalnie)", - "History (:count pending)": "Historia (oczekujące: :count)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Jak długo zaproszenie wysłane przez zespół pozostaje ważne, zanim zaproszony adres będzie musiał poprosić o nowe.", "If that invitation can still be resent, a new one is on its way.": "Jeśli to zaproszenie można jeszcze wysłać ponownie, nowe jest już w drodze.", "Invitation links expire after (hours)": "Linki z zaproszeniem wygasają po (godzinach)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "To zaproszenie nie jest już ważne. Poproś osobę, która Cię zaprosiła, o nowe.", "You've been invited to register": "Zaproszono Cię do rejestracji", "You've been invited to register a client account. The link below will let you set your own password.": "Zaproszono Cię do zarejestrowania konta klienta. Poniższy link pozwoli Ci ustawić własne hasło.", - "Your account has been created. You will be able to log in once it is approved.": "Twoje konto zostało utworzone. Zalogujesz się, gdy zostanie zatwierdzone." + "Your account has been created. You will be able to log in once it is approved.": "Twoje konto zostało utworzone. Zalogujesz się, gdy zostanie zatwierdzone.", + "Invite a client to share files with": "Zaproś klienta, któremu będziesz udostępniać pliki" } diff --git a/lang/pt_BR.json b/lang/pt_BR.json index f39b4508..2ab427cc 100644 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -2045,7 +2045,6 @@ "Create account": "Criar conta", "Create your account": "Crie sua conta", "Group (optional)": "Grupo (opcional)", - "History (:count pending)": "Histórico (pendentes: :count)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Por quanto tempo um convite enviado pela equipe continua válido antes que o endereço convidado precise pedir outro.", "If that invitation can still be resent, a new one is on its way.": "Se esse convite ainda puder ser reenviado, um novo já está a caminho.", "Invitation links expire after (hours)": "Os convites expiram após (horas)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Este convite não é mais válido. Peça a quem convidou você que envie um novo.", "You've been invited to register": "Você foi convidado a se cadastrar", "You've been invited to register a client account. The link below will let you set your own password.": "Você foi convidado a cadastrar uma conta de cliente. O link abaixo permitirá que você defina sua própria senha.", - "Your account has been created. You will be able to log in once it is approved.": "Sua conta foi criada. Você poderá entrar assim que ela for aprovada." + "Your account has been created. You will be able to log in once it is approved.": "Sua conta foi criada. Você poderá entrar assim que ela for aprovada.", + "Invite a client to share files with": "Convide um cliente com quem compartilhar arquivos" } diff --git a/lang/ru.json b/lang/ru.json index 31fe2be7..b99bd907 100644 --- a/lang/ru.json +++ b/lang/ru.json @@ -2045,7 +2045,6 @@ "Create account": "Создать учётную запись", "Create your account": "Создайте учётную запись", "Group (optional)": "Группа (необязательно)", - "History (:count pending)": "История (ожидают: :count)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Сколько времени приглашение, отправленное командой, остаётся действительным, прежде чем приглашённому адресу придётся запросить новое.", "If that invitation can still be resent, a new one is on its way.": "Если это приглашение ещё можно отправить повторно, новое уже в пути.", "Invitation links expire after (hours)": "Ссылки-приглашения действуют (часов)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Это приглашение больше не действует. Попросите того, кто вас пригласил, отправить новое.", "You've been invited to register": "Вас пригласили зарегистрироваться", "You've been invited to register a client account. The link below will let you set your own password.": "Вас пригласили зарегистрировать учётную запись клиента. Ссылка ниже позволит вам задать собственный пароль.", - "Your account has been created. You will be able to log in once it is approved.": "Ваша учётная запись создана. Вы сможете войти, как только её одобрят." + "Your account has been created. You will be able to log in once it is approved.": "Ваша учётная запись создана. Вы сможете войти, как только её одобрят.", + "Invite a client to share files with": "Пригласите клиента, с которым будете делиться файлами" } diff --git a/lang/sw.json b/lang/sw.json index 0527c0e4..43a0d17e 100644 --- a/lang/sw.json +++ b/lang/sw.json @@ -2045,7 +2045,6 @@ "Create account": "Fungua akaunti", "Create your account": "Fungua akaunti yako", "Group (optional)": "Kikundi (hiari)", - "History (:count pending)": "Historia (zinasubiri: :count)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Muda ambao mwaliko uliotumwa na wafanyakazi hubaki halali kabla anwani iliyoalikwa kuomba mwingine.", "If that invitation can still be resent, a new one is on its way.": "Kama mwaliko huo bado unaweza kutumwa tena, mpya unakuja.", "Invitation links expire after (hours)": "Viungo vya mwaliko huisha baada ya (saa)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Mwaliko huu si halali tena. Mwombe aliyekualika akutumie mpya.", "You've been invited to register": "Umealikwa kujisajili", "You've been invited to register a client account. The link below will let you set your own password.": "Umealikwa kusajili akaunti ya mteja. Kiungo kilicho hapa chini kitakuruhusu kuweka nenosiri lako mwenyewe.", - "Your account has been created. You will be able to log in once it is approved.": "Akaunti yako imeundwa. Utaweza kuingia mara itakapoidhinishwa." + "Your account has been created. You will be able to log in once it is approved.": "Akaunti yako imeundwa. Utaweza kuingia mara itakapoidhinishwa.", + "Invite a client to share files with": "Alika mteja wa kushirikiana naye mafaili" } diff --git a/lang/tr.json b/lang/tr.json index 1e16c9d5..8da3555f 100644 --- a/lang/tr.json +++ b/lang/tr.json @@ -2045,7 +2045,6 @@ "Create account": "Hesap oluştur", "Create your account": "Hesabınızı oluşturun", "Group (optional)": "Grup (isteğe bağlı)", - "History (:count pending)": "Geçmiş (:count bekliyor)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Ekibin gönderdiği bir davetin, davet edilen adresin yenisini istemesi gerekene kadar ne kadar süre geçerli kalacağı.", "If that invitation can still be resent, a new one is on its way.": "O davet hâlâ yeniden gönderilebiliyorsa, yenisi yola çıktı.", "Invitation links expire after (hours)": "Davet bağlantıları şu süre sonunda dolar (saat)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Bu davet artık geçerli değil. Sizi davet eden kişiden yenisini göndermesini isteyin.", "You've been invited to register": "Kaydolmaya davet edildiniz", "You've been invited to register a client account. The link below will let you set your own password.": "Bir müşteri hesabı oluşturmaya davet edildiniz. Aşağıdaki bağlantı kendi parolanızı belirlemenizi sağlar.", - "Your account has been created. You will be able to log in once it is approved.": "Hesabınız oluşturuldu. Onaylandığında giriş yapabileceksiniz." + "Your account has been created. You will be able to log in once it is approved.": "Hesabınız oluşturuldu. Onaylandığında giriş yapabileceksiniz.", + "Invite a client to share files with": "Dosya paylaşacağınız bir müşteriyi davet edin" } diff --git a/lang/vi.json b/lang/vi.json index b7aed8d4..5faa9297 100644 --- a/lang/vi.json +++ b/lang/vi.json @@ -2045,7 +2045,6 @@ "Create account": "Tạo tài khoản", "Create your account": "Tạo tài khoản của bạn", "Group (optional)": "Nhóm (tùy chọn)", - "History (:count pending)": "Lịch sử (:count đang chờ)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "Lời mời do nhân sự gửi còn hiệu lực bao lâu trước khi địa chỉ được mời phải xin lời mời mới.", "If that invitation can still be resent, a new one is on its way.": "Nếu lời mời đó vẫn có thể gửi lại, một lời mời mới đang trên đường tới.", "Invitation links expire after (hours)": "Liên kết mời hết hạn sau (giờ)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "Lời mời này không còn hiệu lực. Hãy nhờ người đã mời bạn gửi lời mời mới.", "You've been invited to register": "Bạn được mời đăng ký", "You've been invited to register a client account. The link below will let you set your own password.": "Bạn được mời đăng ký tài khoản khách hàng. Liên kết bên dưới sẽ cho phép bạn tự đặt mật khẩu.", - "Your account has been created. You will be able to log in once it is approved.": "Tài khoản của bạn đã được tạo. Bạn có thể đăng nhập khi tài khoản được duyệt." + "Your account has been created. You will be able to log in once it is approved.": "Tài khoản của bạn đã được tạo. Bạn có thể đăng nhập khi tài khoản được duyệt.", + "Invite a client to share files with": "Mời một khách hàng để chia sẻ tệp" } diff --git a/lang/zh_CN.json b/lang/zh_CN.json index c11115d4..90c87c37 100644 --- a/lang/zh_CN.json +++ b/lang/zh_CN.json @@ -2045,7 +2045,6 @@ "Create account": "创建账户", "Create your account": "创建你的账户", "Group (optional)": "群组(可选)", - "History (:count pending)": "历史(:count 个待处理)", "How long a staff-sent invitation stays valid before the invited address has to ask for a new one.": "团队发出的邀请在多久之后失效,之后受邀地址需要重新申请。", "If that invitation can still be resent, a new one is on its way.": "如果该邀请仍可重新发送,新的邀请已在路上。", "Invitation links expire after (hours)": "邀请链接有效期(小时)", @@ -2073,5 +2072,6 @@ "This invitation is no longer valid. Ask whoever invited you to send a new one.": "此邀请已失效。请让邀请你的人重新发送一份。", "You've been invited to register": "邀请你注册", "You've been invited to register a client account. The link below will let you set your own password.": "你受邀注册客户账户。通过下面的链接可以设置你自己的密码。", - "Your account has been created. You will be able to log in once it is approved.": "你的账户已创建,通过审核后即可登录。" + "Your account has been created. You will be able to log in once it is approved.": "你的账户已创建,通过审核后即可登录。", + "Invite a client to share files with": "邀请一位与你共享文件的客户" } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 95430a8b..a570cbe1 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -19,6 +19,7 @@ import { KeyRound, LayoutGrid, ListChecks, + MailPlus, MessageSquare, Settings, ShieldCheck, @@ -121,6 +122,19 @@ export function AppSidebar() { }); } + if (can('create_clients')) { + // No badge, unlike the two queues below it: an outstanding + // invitation is waiting on the person who was invited, not on + // anybody here. A number beside this would say "you have three + // things to do" about three things nobody in this installation can + // act on. + clientItems.push({ + title: t('Invitations'), + url: '/clients/invitations', + icon: MailPlus, + }); + } + if (can('manage_groups')) { clientItems.push({ title: t('Groups'), diff --git a/resources/js/pages/clients/invitations.tsx b/resources/js/pages/clients/invitations.tsx new file mode 100644 index 00000000..c85e7d75 --- /dev/null +++ b/resources/js/pages/clients/invitations.tsx @@ -0,0 +1,165 @@ +import { type BreadcrumbItem } from '@/types'; +import { Head, Link, router } from '@inertiajs/react'; + +import { ConfirmDialog } from '@/components/confirm-dialog'; +import Heading from '@/components/heading'; +import { FilterField, ListToolbar } from '@/components/list-toolbar'; +import { Pagination, PaginationMeta } from '@/components/pagination'; +import { TableShell } from '@/components/table-shell'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useFormatDate } from '@/hooks/use-format-date'; +import { ALL, useListQuery } from '@/hooks/use-list-query'; +import { useTranslation } from '@/hooks/use-translation'; +import AppLayout from '@/layouts/app-layout'; + +/** + * What a row is, as the server decided it — "expired" among them, which is + * not a stored status. See Invitation::state(). + */ +type InvitationState = 'pending' | 'expired' | 'redeemed' | 'revoked' | 'superseded'; + +interface InvitationRow { + id: number; + name: string | null; + email: string; + group: string | null; + invited_by: string | null; + created_at: string | null; + expires_at: string; + state: InvitationState; +} + +interface InvitationsProps { + invitations: InvitationRow[]; + pagination: PaginationMeta; + filters: { status: string | null }; +} + +export default function Invitations({ invitations, pagination, filters }: InvitationsProps) { + const { t } = useTranslation(); + const { dateTime } = useFormatDate(); + + const { values, set, reset, hasFilters } = useListQuery('invitations.index', { status: filters.status ?? ALL }, { status: ALL }); + + const breadcrumbs: BreadcrumbItem[] = [{ title: t('Invitations'), href: '/clients/invitations' }]; + + // Every state a row can be in, said once. "Expired" and "Replaced" are + // the two a reader needs told apart: one is a link that simply ran out, + // the other was retired by a newer invitation to the same address. + const stateLabels: Record = { + pending: t('Pending'), + expired: t('Expired'), + redeemed: t('Accepted'), + revoked: t('Revoked'), + superseded: t('Replaced'), + }; + + const stateVariants: Record = { + pending: 'default', + expired: 'destructive', + redeemed: 'secondary', + revoked: 'outline', + superseded: 'outline', + }; + + return ( + + + +
+
+ + {/* No permission check on this screen: every route that + reaches it already requires create_clients, so somebody + looking at this list may always send one and revoke one. */} + +
+ + + + + + + + {hasFilters ? t('No invitations match this filter.') : t('No invitations have been sent yet.')}} + > + {invitations.map((invitation) => ( + + + {invitation.email} + {invitation.name && {invitation.name}} + + + {stateLabels[invitation.state]} + + {invitation.group ?? '—'} + {invitation.invited_by ?? '—'} + {dateTime(invitation.created_at)} + + {/* Only a live link has an expiry worth reading. On a + settled row the date is still stored and still true, + and saying it invites somebody to wonder what expires + about an invitation that was accepted. */} + {invitation.state === 'pending' || invitation.state === 'expired' ? dateTime(invitation.expires_at) : '—'} + + +
+ {/* Only a live link can be revoked. A settled row is + history, and offering a button that would 404 is + worse than offering none. */} + {(invitation.state === 'pending' || invitation.state === 'expired') && ( + + {t('Revoke')} + + } + title={t('Revoke this invitation?')} + description={t( + 'The link sent to :email stops working, and cannot be renewed by whoever holds it. You can send a new invitation at any time.', + { email: invitation.email }, + )} + confirmLabel={t('Revoke')} + // preserveState so the page comes back on the + // filter the person was reading. + onConfirm={() => + router.delete(route('invitations.destroy', invitation.id), { + preserveState: true, + preserveScroll: true, + }) + } + /> + )} +
+ + + ))} +
+ + +
+
+ ); +} diff --git a/resources/js/pages/clients/invite.tsx b/resources/js/pages/clients/invite.tsx index a82e26da..960bbb2a 100644 --- a/resources/js/pages/clients/invite.tsx +++ b/resources/js/pages/clients/invite.tsx @@ -1,31 +1,16 @@ import { type BreadcrumbItem } from '@/types'; -import { Head, router, useForm } from '@inertiajs/react'; -import { FormEventHandler, useState } from 'react'; +import { Head, useForm } from '@inertiajs/react'; +import { FormEventHandler } from 'react'; -import { ConfirmDialog } from '@/components/confirm-dialog'; import Heading from '@/components/heading'; import InputError from '@/components/input-error'; -import { FilterField, ListToolbar } from '@/components/list-toolbar'; -import { Pagination, PaginationMeta } from '@/components/pagination'; -import { TableShell } from '@/components/table-shell'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Select, SelectContent, SelectItem, SelectValue, SelectTrigger } from '@/components/ui/select'; -import { useFormatDate } from '@/hooks/use-format-date'; -import { ALL, useListQuery } from '@/hooks/use-list-query'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useTranslation } from '@/hooks/use-translation'; import AppLayout from '@/layouts/app-layout'; -type Tab = 'history' | 'send'; - -/** - * What a row is, as the server decided it — "expired" among them, which is - * not a stored status. See Invitation::state(). - */ -type InvitationState = 'pending' | 'expired' | 'redeemed' | 'revoked' | 'superseded'; - interface InvitationFormData { [key: string]: string; email: string; @@ -34,69 +19,19 @@ interface InvitationFormData { storage_quota_mb: string; } -interface InvitationRow { - id: number; - name: string | null; - email: string; - group: string | null; - invited_by: string | null; - created_at: string | null; - expires_at: string; - state: InvitationState; -} - interface ClientsInviteProps { groups: { id: number; name: string }[]; default_storage_quota_mb: number; - invitations: InvitationRow[]; - pagination: PaginationMeta; - filters: { status: string | null }; - /** Live invitations across the whole table, not this filtered page. */ - pending_count: number; } -export default function ClientsInvite({ - groups, - default_storage_quota_mb, - invitations, - pagination, - filters, - pending_count, -}: ClientsInviteProps) { +export default function ClientsInvite({ groups, default_storage_quota_mb }: ClientsInviteProps) { const { t } = useTranslation(); - const { dateTime } = useFormatDate(); - // The history opens first: arriving here, the question is usually "who - // have we already invited" — including the one you were about to invite - // again. ?tab=send goes straight to the form for anything that means to - // link at it, and anything unrecognised falls back to the history. - const [tab, setTab] = useState(new URLSearchParams(window.location.search).get('tab') === 'send' ? 'send' : 'history'); - - const { values, set, reset, hasFilters } = useListQuery('invitations.create', { status: filters.status ?? ALL }, { status: ALL }); const breadcrumbs: BreadcrumbItem[] = [ - { title: t('Clients'), href: '/clients' }, - { title: t('Invitations'), href: '/clients/invite' }, + { title: t('Invitations'), href: '/clients/invitations' }, + { title: t('Invite client'), href: '/clients/invitations/create' }, ]; - // Every state a row can be in, said once. "Expired" and "Replaced" are - // the two a reader needs told apart: one is a link that simply ran out, - // the other was retired by a newer invitation to the same address. - const stateLabels: Record = { - pending: t('Pending'), - expired: t('Expired'), - redeemed: t('Accepted'), - revoked: t('Revoked'), - superseded: t('Replaced'), - }; - - const stateVariants: Record = { - pending: 'default', - expired: 'destructive', - redeemed: 'secondary', - revoked: 'outline', - superseded: 'outline', - }; - const { data, setData, post, processing, errors } = useForm({ email: '', name: '', @@ -116,31 +51,9 @@ export default function ClientsInvite({
- + - - - {/* Hidden rather than unmounted, the same as the staff account - form's tabs: switching to the list and back must not throw - away a half-typed invitation. */} -
+
- - {tab === 'history' && ( -
- - - - - - - {hasFilters ? t('No invitations match this filter.') : t('No invitations have been sent yet.')}} - > - {invitations.map((invitation) => ( - - - {invitation.email} - {invitation.name && {invitation.name}} - - - {stateLabels[invitation.state]} - - {invitation.group ?? '—'} - {invitation.invited_by ?? '—'} - {dateTime(invitation.created_at)} - - {/* Only a live link has an expiry worth reading. - On a settled row the date is still stored and - still true, and saying it invites somebody to - wonder what expires about an invitation that - was accepted. */} - {invitation.state === 'pending' || invitation.state === 'expired' ? dateTime(invitation.expires_at) : '—'} - - -
- {/* Only a live link can be revoked. A settled - row is history, and offering a button that - would 404 is worse than offering none. */} - {(invitation.state === 'pending' || invitation.state === 'expired') && ( - - {t('Revoke')} - - } - title={t('Revoke this invitation?')} - description={t( - 'The link sent to :email stops working, and cannot be renewed by whoever holds it. You can send a new invitation at any time.', - { email: invitation.email }, - )} - confirmLabel={t('Revoke')} - // preserveState so the page comes back on this - // tab, and on the filter the person was reading, - // rather than on the form. - onConfirm={() => - router.delete(route('invitations.destroy', invitation.id), { - preserveState: true, - preserveScroll: true, - }) - } - /> - )} -
- - - ))} -
- - -
- )}
); diff --git a/routes/web.php b/routes/web.php index a0e70e88..884a7b47 100644 --- a/routes/web.php +++ b/routes/web.php @@ -277,11 +277,13 @@ Route::middleware(['auth'])->group(function () { // Invite shares create_clients rather than a capability of its own, // same reasoning as the note above: an installation that may add a // client by hand may also ask one to set their own password. - Route::get('clients/invite', [InvitationController::class, 'create'])->middleware(['staff', 'can:create_clients'])->name('invitations.create'); - Route::post('clients/invite', [InvitationController::class, 'store'])->middleware(['staff', 'can:create_clients'])->name('invitations.store'); - // Cancelling is the same authority as sending: whoever may invite - // somebody may take it back. - Route::delete('clients/invite/{invitation}', [InvitationController::class, 'destroy'])->middleware(['staff', 'can:create_clients'])->name('invitations.destroy'); + // Sending and cancelling are the same authority, and so is reading the + // history: whoever may invite somebody may see who has been invited + // and take it back. + Route::get('clients/invitations', [InvitationController::class, 'index'])->middleware(['staff', 'can:create_clients'])->name('invitations.index'); + Route::get('clients/invitations/create', [InvitationController::class, 'create'])->middleware(['staff', 'can:create_clients'])->name('invitations.create'); + Route::post('clients/invitations', [InvitationController::class, 'store'])->middleware(['staff', 'can:create_clients'])->name('invitations.store'); + Route::delete('clients/invitations/{invitation}', [InvitationController::class, 'destroy'])->middleware(['staff', 'can:create_clients'])->name('invitations.destroy'); Route::get('clients/{client}/files', [ClientFilesController::class, 'index'])->middleware(['staff', 'can:edit_clients'])->name('clients.files'); Route::get('clients/{client}', [ClientsController::class, 'edit'])->middleware(['staff', 'can:edit_clients'])->name('clients.edit'); Route::patch('clients/{client}', [ClientsController::class, 'update'])->middleware(['staff', 'can:edit_clients'])->name('clients.update'); diff --git a/tests/Feature/Clients/ClientInvitationTest.php b/tests/Feature/Clients/ClientInvitationTest.php index 8810f52e..1819e2b4 100644 --- a/tests/Feature/Clients/ClientInvitationTest.php +++ b/tests/Feature/Clients/ClientInvitationTest.php @@ -22,11 +22,11 @@ beforeEach(function () { test('staff can send an invitation and it emails the invited address', function () { Notification::fake(); - $this->actingAs($this->admin)->post('/clients/invite', [ + $this->actingAs($this->admin)->post('/clients/invitations', [ 'email' => 'invited@example.com', 'name' => 'Invited Person', 'group_id' => 0, - ])->assertRedirect(route('clients.index')); + ])->assertRedirect(route('invitations.index')); $invitation = Invitation::query()->where('email', 'invited@example.com')->sole(); expect($invitation->name)->toBe('Invited Person') @@ -47,7 +47,7 @@ test('a storage quota set on the invitation carries through to the account it cr // $this->post() otherwise preserves whatever PHP type the test itself // wrote — hiding exactly the mismatch a browser's actual POST would // hit against a strictly-typed collaborator. - $this->actingAs($this->admin)->post('/clients/invite', [ + $this->actingAs($this->admin)->post('/clients/invitations', [ 'email' => 'invited@example.com', 'group_id' => 0, 'storage_quota_mb' => '500', @@ -70,7 +70,7 @@ test('a storage quota set on the invitation carries through to the account it cr }); test('leaving the storage quota blank inherits the site default, same as self-registration', function () { - $this->actingAs($this->admin)->post('/clients/invite', [ + $this->actingAs($this->admin)->post('/clients/invitations', [ 'email' => 'invited@example.com', 'group_id' => 0, ]); @@ -82,10 +82,10 @@ test('leaving the storage quota blank inherits the site default, same as self-re test('inviting an already-invited address supersedes the earlier invitation instead of leaving two live tokens', function () { $first = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay()); - $this->actingAs($this->admin)->post('/clients/invite', [ + $this->actingAs($this->admin)->post('/clients/invitations', [ 'email' => 'invited@example.com', 'group_id' => 0, - ])->assertRedirect(route('clients.index')); + ])->assertRedirect(route('invitations.index')); expect($first->fresh()->status)->toBe(Invitation::STATUS_SUPERSEDED) ->and(Invitation::query()->pending()->where('email', 'invited@example.com')->count())->toBe(1); @@ -100,7 +100,7 @@ test('inviting an already-invited address supersedes the earlier invitation inst test('an invitation cannot be sent to an address that already has an account', function () { $existing = User::factory()->client()->create(['email' => 'taken@example.com']); - $this->actingAs($this->admin)->post('/clients/invite', [ + $this->actingAs($this->admin)->post('/clients/invitations', [ 'email' => 'taken@example.com', 'group_id' => 0, ])->assertSessionHasErrors('email'); @@ -113,8 +113,8 @@ test('an invitation cannot be sent to an address that already has an account', f test('clients cannot send invitations', function () { $this->actingAs(User::factory()->client()->create()); - $this->get('/clients/invite')->assertRedirect(route('dashboard')); - $this->post('/clients/invite', ['email' => 'x@example.com', 'group_id' => 0])->assertForbidden(); + $this->get('/clients/invitations')->assertRedirect(route('dashboard')); + $this->post('/clients/invitations', ['email' => 'x@example.com', 'group_id' => 0])->assertForbidden(); }); test('a valid invitation link shows the redemption form with the email locked', function () { @@ -276,7 +276,7 @@ test('a full installation refuses to send an invitation it could not honour', fu config()->set('projectsend.platform.max_clients', 1); User::factory()->client()->create(); - $this->actingAs($this->admin)->post('/clients/invite', [ + $this->actingAs($this->admin)->post('/clients/invitations', [ 'email' => 'invited@example.com', 'group_id' => 0, ])->assertSessionHasErrors('email'); @@ -314,17 +314,15 @@ test('the invitations screen is a history: every invitation ever sent, whatever Invitation::issue('replaced@example.com', null, null, $this->admin, now()->addDay()) ->forceFill(['status' => Invitation::STATUS_SUPERSEDED])->save(); - $this->actingAs($this->admin)->get('/clients/invite')->assertInertia( + $this->actingAs($this->admin)->get('/clients/invitations')->assertInertia( fn (AssertableInertia $page) => $page - ->component('clients/invite') + ->component('clients/invitations') ->has('invitations', 5) - // Only the one that is live and unexpired counts as waiting. - ->where('pending_count', 1) ->where('filters.status', null), ); // Newest first, and each row carries the state the screen labels it by. - $states = collect($this->actingAs($this->admin)->get('/clients/invite')->viewData('page')['props']['invitations']) + $states = collect($this->actingAs($this->admin)->get('/clients/invitations')->viewData('page')['props']['invitations']) ->pluck('state', 'email'); expect($states->all())->toBe([ @@ -344,27 +342,24 @@ test('the history can be filtered down to one status', function () { // "Waiting" and "Expired" are the same stored status told apart by the // clock, which is the pair worth proving the filter gets right. - $this->actingAs($this->admin)->get('/clients/invite?status=pending')->assertInertia( + $this->actingAs($this->admin)->get('/clients/invitations?status=pending')->assertInertia( fn (AssertableInertia $page) => $page ->has('invitations', 1) ->where('invitations.0.email', 'live@example.com') - ->where('filters.status', 'pending') - // Unchanged by the filter: it answers "is anybody waiting", - // not "how many rows am I looking at". - ->where('pending_count', 1), + ->where('filters.status', 'pending'), ); - $this->actingAs($this->admin)->get('/clients/invite?status=expired')->assertInertia( + $this->actingAs($this->admin)->get('/clients/invitations?status=expired')->assertInertia( fn (AssertableInertia $page) => $page->has('invitations', 1)->where('invitations.0.email', 'stale@example.com'), ); - $this->actingAs($this->admin)->get('/clients/invite?status=revoked')->assertInertia( + $this->actingAs($this->admin)->get('/clients/invitations?status=revoked')->assertInertia( fn (AssertableInertia $page) => $page->has('invitations', 1)->where('invitations.0.email', 'gone@example.com'), ); }); test('an unknown status filter is refused rather than quietly ignored', function () { - $this->actingAs($this->admin)->get('/clients/invite?status=whatever')->assertSessionHasErrors('status'); + $this->actingAs($this->admin)->get('/clients/invitations?status=whatever')->assertSessionHasErrors('status'); }); test('staff can revoke an invitation, and the revoked link is dead for good', function () { @@ -459,10 +454,10 @@ test('a staff member sending a new invitation starts the renewal allowance again $spent = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay(), resends: 3); - $this->actingAs($this->admin)->post('/clients/invite', [ + $this->actingAs($this->admin)->post('/clients/invitations', [ 'email' => 'invited@example.com', 'group_id' => 0, - ])->assertRedirect(route('clients.index')); + ])->assertRedirect(route('invitations.index')); $fresh = Invitation::query()->pending()->where('email', 'invited@example.com')->sole(); expect($fresh->resends)->toBe(0) @@ -473,3 +468,13 @@ test('a staff member sending a new invitation starts the renewal allowance again expect(Invitation::query()->pending()->where('email', 'invited@example.com')->sole()->resends)->toBe(1); }); + +test('the invite form and the history are separate screens', function () { + $this->actingAs($this->admin)->get('/clients/invitations/create')->assertInertia( + fn (AssertableInertia $page) => $page->component('clients/invite')->has('groups')->missing('invitations'), + ); + + $this->actingAs($this->admin)->get('/clients/invitations')->assertInertia( + fn (AssertableInertia $page) => $page->component('clients/invitations')->has('invitations')->missing('groups'), + ); +});