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