diff --git a/INSTALL.md b/INSTALL.md
index cba4487d..74b9f8bd 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -275,7 +275,10 @@ edits the nginx config for you.
Open your site in a browser. Because no account exists yet, every address takes you to the setup
screen, which asks for a site name and the name, email and password of the first administrator.
-Fill it in, and you are done — sign in and start adding clients.
+Fill it in, and you are done. The first time you sign in, ProjectSend opens on a short list of the
+things worth doing first — adding a client, uploading a file, choosing how your file lists and your
+email look — each one linking straight to the screen that does it. It appears once; afterwards it
+lives at **About → Getting started**.
If you would rather not do it in the browser (or you are scripting the install), the same thing
from the command line:
diff --git a/app/Http/Middleware/RedirectToGreeting.php b/app/Http/Middleware/RedirectToGreeting.php
new file mode 100644
index 00000000..3daba1d1
--- /dev/null
+++ b/app/Http/Middleware/RedirectToGreeting.php
@@ -0,0 +1,62 @@
+isMethod('GET')) {
+ $user = $request->user();
+
+ if ($user !== null) {
+ if ($this->installation->isWaitingFor($user)) {
+ return redirect()->route('system.getting-started');
+ }
+
+ if ($this->update->isWaitingFor($user)) {
+ return redirect()->route('system.whats-new');
+ }
+ }
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/RedirectToWhatsNew.php b/app/Http/Middleware/RedirectToWhatsNew.php
deleted file mode 100644
index 81d7a9ba..00000000
--- a/app/Http/Middleware/RedirectToWhatsNew.php
+++ /dev/null
@@ -1,43 +0,0 @@
-isMethod('GET')) {
- $user = $request->user();
-
- if ($user !== null && $this->welcome->isWaitingFor($user)) {
- return redirect()->route('system.whats-new');
- }
- }
-
- return $next($request);
- }
-}
diff --git a/app/Modules/Identity/Console/CreateAdminCommand.php b/app/Modules/Identity/Console/CreateAdminCommand.php
index ffdcb323..1e11a2fe 100644
--- a/app/Modules/Identity/Console/CreateAdminCommand.php
+++ b/app/Modules/Identity/Console/CreateAdminCommand.php
@@ -10,6 +10,7 @@ use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
+use App\Modules\Platform\Onboarding\InstallationWelcome;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Console\Command;
@@ -72,6 +73,12 @@ class CreateAdminCommand extends Command
$settings->set(Setting::AdminNotificationEmails, [$user->email]);
}
+ // Unattended provisioning skips the setup screen entirely, so this
+ // is the only place that can record "somebody just installed this"
+ // for a container that came up from environment variables. They
+ // still deserve showing around on their first visit.
+ app(InstallationWelcome::class)->raise();
+
$this->info("Administrator {$user->email} created.");
return self::SUCCESS;
diff --git a/app/Modules/Identity/Http/Controllers/SetupController.php b/app/Modules/Identity/Http/Controllers/SetupController.php
index 6875b08f..221c134c 100644
--- a/app/Modules/Identity/Http/Controllers/SetupController.php
+++ b/app/Modules/Identity/Http/Controllers/SetupController.php
@@ -11,6 +11,7 @@ use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
+use App\Modules\Platform\Onboarding\InstallationWelcome;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\RedirectResponse;
@@ -28,6 +29,7 @@ class SetupController extends Controller
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
+ private readonly InstallationWelcome $welcome,
) {}
public function show(): Response|RedirectResponse
@@ -72,6 +74,11 @@ class SetupController extends Controller
$this->settings->set(Setting::AdminNotificationEmails, [$admin->email]);
}
+ // They will be shown around the first time they sign in — which is
+ // the next thing that happens, since setup deliberately does not
+ // log anybody in.
+ $this->welcome->raise();
+
// Deliberately no auto-login: the new administrator proves their
// credentials at the login form, which also confirms they work.
return redirect()->route('setup.success')->with('setup_completed', true);
diff --git a/app/Modules/Platform/Http/Controllers/GettingStartedController.php b/app/Modules/Platform/Http/Controllers/GettingStartedController.php
new file mode 100644
index 00000000..4ead527e
--- /dev/null
+++ b/app/Modules/Platform/Http/Controllers/GettingStartedController.php
@@ -0,0 +1,49 @@
+user();
+ assert($user !== null);
+
+ $props = [
+ 'items' => $this->quickStart->forUser($user),
+ 'justInstalled' => $this->welcome->pending(),
+ ];
+
+ // Only for the person it was addressed to — another staff member
+ // reading it later is reading, not receiving.
+ if ($this->welcome->isWaitingFor($user)) {
+ $this->welcome->dismiss();
+ }
+
+ return Inertia::render('system/getting-started', $props);
+ }
+}
diff --git a/app/Modules/Platform/Onboarding/InstallationWelcome.php b/app/Modules/Platform/Onboarding/InstallationWelcome.php
new file mode 100644
index 00000000..6c3af3cb
--- /dev/null
+++ b/app/Modules/Platform/Onboarding/InstallationWelcome.php
@@ -0,0 +1,79 @@
+settings->set(Setting::GettingStartedPending, true);
+ }
+
+ public function pending(): bool
+ {
+ return $this->settings->get(Setting::GettingStartedPending) === true;
+ }
+
+ /**
+ * Whether this user should be taken to the page right now.
+ */
+ public function isWaitingFor(User $user): bool
+ {
+ return $this->pending()
+ && $this->mayRead($user)
+ && $this->staff->mainAdministrator()?->is($user) === true;
+ }
+
+ /**
+ * Whether this user may open the page at all.
+ *
+ * Any staff member, and no capability gate: the page is a list of
+ * links to screens they can already reach, each one filtered to what
+ * that person may actually do (see QuickStart). Both editions get it —
+ * a managed installation still has a first client to add and a first
+ * file to upload, which is the whole content.
+ */
+ public function mayRead(User $user): bool
+ {
+ return $user->isStaff();
+ }
+
+ /**
+ * Stop redirecting. The page itself keeps working — somebody who
+ * closed it on their way past should be able to find it again.
+ */
+ public function dismiss(): void
+ {
+ $this->settings->set(Setting::GettingStartedPending, false);
+ }
+}
diff --git a/app/Modules/Platform/Onboarding/QuickStart.php b/app/Modules/Platform/Onboarding/QuickStart.php
new file mode 100644
index 00000000..fc5cec97
--- /dev/null
+++ b/app/Modules/Platform/Onboarding/QuickStart.php
@@ -0,0 +1,147 @@
+
+ */
+ public function forUser(User $user): array
+ {
+ $items = [];
+
+ if ($this->permissions->allows($user, Permission::CreateClients)) {
+ $items[] = [
+ 'key' => 'client',
+ 'title' => __('Add your first client'),
+ 'description' => __('A client is somebody you send files to. They get their own account and see only what you share with them.'),
+ 'href' => route('clients.create', absolute: false),
+ 'done' => $this->hasAClient(),
+ ];
+ }
+
+ if ($this->permissions->allows($user, Permission::Upload)) {
+ $items[] = [
+ 'key' => 'upload',
+ 'title' => __('Upload a file'),
+ 'description' => __('Drop a file in and choose who it goes to. Uploads resume by themselves if the connection drops.'),
+ 'href' => route('files.create', absolute: false),
+ 'done' => $this->hasAFile(),
+ ];
+ }
+
+ if ($this->permissions->allows($user, Permission::CreateGroups)) {
+ $items[] = [
+ 'key' => 'group',
+ 'title' => __('Group the clients who get the same things'),
+ 'description' => __('Share with a group once instead of with six people individually, and anyone added later gets it too.'),
+ 'href' => route('groups.create', absolute: false),
+ 'done' => false,
+ ];
+ }
+
+ if ($this->permissions->allows($user, Permission::EditSettings)) {
+ $items[] = [
+ 'key' => 'theme',
+ 'title' => __('Choose how your file lists look'),
+ 'description' => __('Four layouts for the pages your clients and visitors see. Each one previews before you switch.'),
+ 'href' => route('system-settings.theming.edit', absolute: false),
+ 'done' => false,
+ ];
+
+ $items[] = [
+ 'key' => 'email-theme',
+ 'title' => __('Choose how your email looks'),
+ 'description' => __('Four themes for the messages ProjectSend sends, previewed on a real message rather than a mock-up.'),
+ 'href' => route('system-settings.theming.edit', ['tab' => 'email'], absolute: false),
+ 'done' => false,
+ ];
+ }
+
+ // Community only: on a managed installation the mail server is
+ // ours, and there is nothing here to point anywhere.
+ if ($this->permissions->allows($user, Permission::EditSettings)
+ && $this->capabilities->has(Capability::EmailTransportConfigure)) {
+ $items[] = [
+ 'key' => 'email',
+ 'title' => __('Point ProjectSend at your mail server'),
+ 'description' => __('Notifications, password resets and share links all arrive by email, so this is worth doing before your first client does.'),
+ 'href' => route('system-settings.email.edit', absolute: false),
+ 'done' => false,
+ ];
+ }
+
+ // Community only, and the example the brief named: a managed
+ // installation has no staff accounts of its own to hand out.
+ if ($this->permissions->allows($user, Permission::CreateUsers)
+ && $this->capabilities->has(Capability::UsersManage)) {
+ $items[] = [
+ 'key' => 'team',
+ 'title' => __('Add the rest of your team'),
+ 'description' => __('Staff accounts with roles, so people get exactly the part of this they need and nothing else.'),
+ 'href' => route('users.create', absolute: false),
+ 'done' => false,
+ ];
+ }
+
+ // Community only: scheduled work is somebody else's problem on a
+ // managed installation, and its screen does not exist there.
+ if ($this->permissions->allows($user, Permission::ViewSystemInfo)
+ && $this->capabilities->has(Capability::SchedulerMonitoring)) {
+ $items[] = [
+ 'key' => 'scheduler',
+ 'title' => __('Check the scheduler is running'),
+ 'description' => __('Expiring files, cleanups and queued email all depend on it. This screen tells you whether it has run.'),
+ 'href' => route('system-settings.scheduler.index', absolute: false),
+ 'done' => false,
+ ];
+ }
+
+ return $items;
+ }
+
+ private function hasAClient(): bool
+ {
+ return User::query()->where('type', UserType::Client)->exists();
+ }
+
+ private function hasAFile(): bool
+ {
+ return File::query()->exists();
+ }
+}
diff --git a/app/Modules/Platform/Settings/Setting.php b/app/Modules/Platform/Settings/Setting.php
index 115e6f68..56b3cda3 100644
--- a/app/Modules/Platform/Settings/Setting.php
+++ b/app/Modules/Platform/Settings/Setting.php
@@ -259,6 +259,16 @@ enum Setting: string
case UpdateWelcomeFrom = 'update_welcome_from';
case UpdateWelcomeTo = 'update_welcome_to';
+ // This installation has just been installed and its administrator has
+ // not been shown the getting-started page yet.
+ //
+ // Raised where the first administrator is created — the setup screen
+ // and the provisioning command both — and cleared when the page is
+ // read. False by default, which is what keeps an installation that
+ // updates into this feature from being congratulated on an
+ // installation it completed a year ago.
+ case GettingStartedPending = 'getting_started_pending';
+
// Cached result of the last dashboard news feed fetch — never written
// directly by a settings form, only by FetchNewsCommand. Both editions
// see this (unlike CheckForUpdates above, which is Community-only);
@@ -330,7 +340,8 @@ enum Setting: string
self::CaptchaOnRegistration,
self::CaptchaOnPasswordReset,
self::CaptchaOnPublicComments,
- self::PasswordRejectBreached => SettingType::Boolean,
+ self::PasswordRejectBreached,
+ self::GettingStartedPending => SettingType::Boolean,
self::ClientsAutoGroup,
self::ClientsMembershipDenyCooldownDays,
@@ -440,6 +451,10 @@ enum Setting: string
self::AppliedVersionAt => '',
self::UpdateWelcomeFrom => '',
self::UpdateWelcomeTo => '',
+ // False, so that an installation which updates into this
+ // feature is not welcomed to an installation it finished a
+ // year ago. Only creating the first administrator raises it.
+ self::GettingStartedPending => false,
self::NewsLastFetchedAt => '',
self::NewsItems => [],
diff --git a/resources/js/pages/system/about.tsx b/resources/js/pages/system/about.tsx
index ab9e03d4..577adffb 100644
--- a/resources/js/pages/system/about.tsx
+++ b/resources/js/pages/system/about.tsx
@@ -73,6 +73,9 @@ export default function About({ license, environment }: AboutProps) {
{t('Discord')}
+
+ {t('Getting started')}
+
{/* Same gate as the environment block below, which is
why it rides on the same prop: the page it links to
answers the question this one starts. */}
diff --git a/resources/js/pages/system/getting-started.tsx b/resources/js/pages/system/getting-started.tsx
new file mode 100644
index 00000000..c0163728
--- /dev/null
+++ b/resources/js/pages/system/getting-started.tsx
@@ -0,0 +1,118 @@
+import { type BreadcrumbItem, type SharedData } from '@/types';
+import { Head, Link, usePage } from '@inertiajs/react';
+import { ArrowRight, Check, MessagesSquare } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { useTranslation } from '@/hooks/use-translation';
+import AppLayout from '@/layouts/app-layout';
+
+interface QuickStartItem {
+ key: string;
+ title: string;
+ description: string;
+ href: string;
+ /** True only where the database can answer it — see QuickStart. */
+ done: boolean;
+}
+
+interface GettingStartedProps {
+ items: QuickStartItem[];
+ /** False once the page has been read, or when opened from a link later. */
+ justInstalled: boolean;
+}
+
+export default function GettingStarted({ items, justInstalled }: GettingStartedProps) {
+ const { t } = useTranslation();
+ const { name, links } = usePage().props;
+
+ const breadcrumbs: BreadcrumbItem[] = [{ title: t('Getting started'), href: '/system/getting-started' }];
+
+ return (
+
+
+
+
+
+
+
+ {justInstalled ? t('Welcome to ProjectSend') : t('Getting started')}
+
+
+
+ {justInstalled
+ ? t(
+ ':name is installed and yours. Thank you for choosing software you can run yourself — here is the short version of what to do next.',
+ { name },
+ )
+ : t('The short version of what to do on a new installation. Nothing here expires.')}
+
+
+
+
+ {items.map((item, index) => (
+
+
+ {/* The step number becomes a tick where the
+ database can actually answer whether it
+ has been done. */}
+
+ {item.done ? : index + 1}
+
+
+
+
+ {item.title}
+
+ {item.description}
+
+
+
+
+
+ ))}
+
+
+ {/* Last, deliberately. Somebody who has just installed this
+ came here with a job in mind, and the fastest way to
+ lose them is to lead with a social invitation. It is
+ still worth making — after the work, not in front of
+ it. */}
+
+
+
+
+
{t('Come and say hello')}
+
+ {t(
+ 'ProjectSend has a Discord: release news, help when something is not behaving, and other people running the same software. We are in it too.',
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/pages/system/settings/theming.tsx b/resources/js/pages/system/settings/theming.tsx
index dd084012..a58755a9 100644
--- a/resources/js/pages/system/settings/theming.tsx
+++ b/resources/js/pages/system/settings/theming.tsx
@@ -83,7 +83,11 @@ function ThemeCard({
export default function ThemingSettings({ theme, email_theme, themes, email_themes }: ThemingSettingsProps) {
const { t } = useTranslation();
- const [tab, setTab] = useState('pages');
+ // ?tab=email opens on the email themes, so something elsewhere can link
+ // at one of these two halves rather than at the page and a sentence
+ // asking the reader to find the right tab. Anything unrecognised falls
+ // back to the pages tab rather than rendering nothing.
+ const [tab, setTab] = useState(new URLSearchParams(window.location.search).get('tab') === 'email' ? 'email' : 'pages');
const [activatingKey, setActivatingKey] = useState(null);
const [previewing, setPreviewing] = useState(null);
diff --git a/routes/settings.php b/routes/settings.php
index 7e7b345c..cf598b86 100644
--- a/routes/settings.php
+++ b/routes/settings.php
@@ -19,6 +19,7 @@ use App\Modules\Platform\Http\Controllers\CaptchaSettingsController;
use App\Modules\Platform\Http\Controllers\EmailSettingsController;
use App\Modules\Platform\Http\Controllers\EmailTemplatesController;
use App\Modules\Platform\Http\Controllers\ExternalStorageSettingsController;
+use App\Modules\Platform\Http\Controllers\GettingStartedController;
use App\Modules\Platform\Http\Controllers\LanguageSettingsController;
use App\Modules\Platform\Http\Controllers\PrivacySettingsController;
use App\Modules\Platform\Http\Controllers\PublicListingSettingsController;
@@ -111,6 +112,14 @@ Route::middleware('auth')->group(function () {
// edit.
Route::middleware('staff')->get('system/about', AboutController::class)->name('system.about');
+ // Where a new installation's administrator is sent on their first
+ // visit, and a page any staff member can come back to. `staff` alone:
+ // it is a list of links to screens they can already reach, and
+ // QuickStart filters it to the ones they may actually use.
+ Route::middleware('staff')
+ ->get('system/getting-started', GettingStartedController::class)
+ ->name('system.getting-started');
+
// Where an administrator is sent after an update, and a page anyone
// who may read About's environment block can revisit afterwards. The
// capability keeps it off managed installations, where nobody signed
diff --git a/routes/web.php b/routes/web.php
index f7f43d33..d99262f2 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,6 +1,6 @@
middleware('thr
Route::get('s/{token}/download', [PublicShareController::class, 'download'])->middleware('throttle:30,1,share-link')->name('share.download');
Route::middleware(['auth'])->group(function () {
- // The welcome middleware sits here and nowhere else — see the class
+ // The greeting middleware sits here and nowhere else — see the class
// for why the dashboard is the right and only place to intercept.
Route::get('dashboard', DashboardController::class)
- ->middleware(RedirectToWhatsNew::class)
+ ->middleware(RedirectToGreeting::class)
->name('dashboard');
Route::put('dashboard/widgets', [DashboardWidgetPreferencesController::class, 'update'])->name('dashboard.widgets.update');
diff --git a/tests/Feature/Platform/GettingStartedTest.php b/tests/Feature/Platform/GettingStartedTest.php
new file mode 100644
index 00000000..02f6a319
--- /dev/null
+++ b/tests/Feature/Platform/GettingStartedTest.php
@@ -0,0 +1,206 @@
+admin = User::factory()->create();
+
+ // Settings survive RefreshDatabase's rollback in the cache, so state
+ // both markers rather than assuming their defaults.
+ app(Settings::class)->set(Setting::GettingStartedPending, false);
+ app(Settings::class)->set(Setting::UpdateWelcomeTo, '');
+});
+
+function justInstalled(): void
+{
+ app(Settings::class)->set(Setting::GettingStartedPending, true);
+}
+
+/** @return list */
+function quickStartKeys(User $user): array
+{
+ $keys = [];
+
+ test()->actingAs($user)->get('/system/getting-started')->assertInertia(
+ function (AssertableInertia $page) use (&$keys) {
+ $keys = array_column($page->toArray()['props']['items'], 'key');
+ },
+ );
+
+ return $keys;
+}
+
+test('the main administrator lands on it after installing', function () {
+ justInstalled();
+
+ $this->actingAs($this->admin)->get('/dashboard')->assertRedirect('/system/getting-started');
+});
+
+test('it happens exactly once', function () {
+ justInstalled();
+
+ $this->actingAs($this->admin)->get('/dashboard')->assertRedirect('/system/getting-started');
+ $this->actingAs($this->admin)->get('/system/getting-started')->assertOk();
+
+ $this->actingAs($this->admin)->get('/dashboard')->assertOk();
+});
+
+// Closing it on the way past should not be unrecoverable.
+test('it stays readable afterwards, with the welcome wording dropped', function () {
+ justInstalled();
+
+ $this->actingAs($this->admin)->get('/system/getting-started')->assertOk();
+
+ $this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(
+ fn (AssertableInertia $page) => $page
+ ->component('system/getting-started')
+ ->where('justInstalled', false)
+ ->has('items'),
+ );
+});
+
+test('other staff are not interrupted, but may read it', function () {
+ justInstalled();
+
+ $second = User::factory()->create();
+
+ $this->actingAs($second)->get('/dashboard')->assertOk();
+ $this->actingAs($second)->get('/system/getting-started')->assertOk();
+
+ // …and reading it did not consume the greeting.
+ $this->actingAs($this->admin)->get('/dashboard')->assertRedirect('/system/getting-started');
+});
+
+test('clients cannot reach it', function () {
+ $client = User::factory()->client()->create();
+
+ // EnsureStaff redirects a client away from a staff GET rather than
+ // answering 403 — see its docblock.
+ $this->actingAs($client)->get('/system/getting-started')->assertRedirect();
+});
+
+test('an installation that merely updated is never welcomed to itself', function () {
+ app(Settings::class)->set(Setting::UpdateWelcomeFrom, '2.0.0');
+ app(Settings::class)->set(Setting::UpdateWelcomeTo, '2.1.0');
+
+ $this->actingAs($this->admin)->get('/dashboard')->assertRedirect('/system/whats-new');
+});
+
+// Both markers at once cannot happen in practice — an update marker is
+// only raised for an installation that already existed — but if it did,
+// release notes for a version they never ran are the wrong greeting.
+test('installing wins over updating', function () {
+ justInstalled();
+ app(Settings::class)->set(Setting::UpdateWelcomeTo, '2.1.0');
+
+ $this->actingAs($this->admin)->get('/dashboard')->assertRedirect('/system/getting-started');
+});
+
+test('completing setup raises the greeting', function () {
+ User::query()->delete();
+
+ $this->post('/setup', [
+ 'site_name' => 'Acme Files',
+ 'name' => 'Ada',
+ 'email' => 'ada@example.com',
+ 'password' => 'a-long-enough-password',
+ 'password_confirmation' => 'a-long-enough-password',
+ ])->assertRedirect('/setup/success');
+
+ expect(app(Settings::class)->get(Setting::GettingStartedPending))->toBeTrue();
+});
+
+// Unattended provisioning skips the setup screen entirely, and is how
+// every container that came up from environment variables was installed.
+test('provisioning from the command line raises it too', function () {
+ User::query()->delete();
+
+ $this->artisan('projectsend:admin', [
+ '--name' => 'Ada',
+ '--email' => 'ada@example.com',
+ '--password' => 'a-long-enough-password',
+ ])->assertSuccessful();
+
+ expect(app(Settings::class)->get(Setting::GettingStartedPending))->toBeTrue();
+});
+
+test('--if-none on an installed site raises nothing', function () {
+ $this->artisan('projectsend:admin', ['--if-none' => true])->assertSuccessful();
+
+ expect(app(Settings::class)->get(Setting::GettingStartedPending))->toBeFalse();
+});
+
+// The list is the point of the page, and a link to a screen that answers
+// 403 is worse than no link at all.
+test('it only lists what this person may actually do', function () {
+ $uploader = User::factory()->role(SystemRole::Uploader)->create();
+
+ $keys = quickStartKeys($uploader);
+
+ expect($keys)->toContain('upload')
+ ->and($keys)->not->toContain('team')
+ ->and($keys)->not->toContain('email')
+ ->and($keys)->not->toContain('theme');
+});
+
+// The example the brief named: a managed installation has no staff
+// accounts of its own to hand out, no mail server to point anywhere and
+// no scheduler to check.
+test('a managed installation is not sent to screens it does not have', function () {
+ config()->set('projectsend.edition', Edition::Cloud);
+
+ $keys = quickStartKeys($this->admin);
+
+ expect($keys)->toContain('client', 'upload', 'theme', 'email-theme')
+ ->and($keys)->not->toContain('team')
+ ->and($keys)->not->toContain('email')
+ ->and($keys)->not->toContain('scheduler');
+});
+
+test('a self-hosted installation gets the full list', function () {
+ config()->set('projectsend.edition', Edition::Community);
+
+ expect(quickStartKeys($this->admin))->toContain('client', 'upload', 'group', 'theme', 'email-theme', 'email', 'team', 'scheduler');
+});
+
+// Two of them can be answered from the database rather than guessed, and
+// a tick that means "we assume so" would be worse than no tick.
+test('the client and upload steps tick themselves', function () {
+ justInstalled();
+
+ $this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(
+ fn (AssertableInertia $page) => $page->where('items.0.key', 'client')->where('items.0.done', false),
+ );
+
+ User::factory()->client()->create();
+
+ $this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(
+ fn (AssertableInertia $page) => $page->where('items.0.done', true),
+ );
+});
+
+// The email-theme link has to land on the email tab, not on the page with
+// a sentence asking the reader to find it.
+test('the email theme step deep-links to its own tab', function () {
+ $items = [];
+
+ $this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(function (AssertableInertia $page) use (&$items) {
+ $items = $page->toArray()['props']['items'];
+ });
+
+ $emailTheme = collect($items)->firstWhere('key', 'email-theme');
+
+ expect($emailTheme['href'])->toContain('tab=email');
+});