From 4ce6793da9366fb1e7dc133eebcc1db8b4dac2f5 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Sat, 15 Aug 2026 19:44:22 -0300 Subject: [PATCH] Show a new installation's administrator around, once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup ended by handing somebody a login form and an empty dashboard. Everything this application can do was one menu away, and which menu was theirs to discover. The first time the administrator signs in to a new installation they now land on a short ordered list of what is worth doing first — add a client, upload a file, group the people who get the same things, choose how the file lists and the email look, point it at a mail server, add the team, check the scheduler — each a link straight to the screen that does it. The list is filtered twice, and both filters matter. By permission, because a link that answers 403 is worse than no link. And by edition: a managed installation is not sent off to configure a mail server somebody else runs, to create staff accounts that are not its to create, or to check a scheduler it does not host. Those three drop out on Cloud and the other five remain. Two steps tick themselves, because the database can answer them: a client exists, a file exists. Nothing else is checkable without guessing — a theme that was never changed looks exactly like one chosen deliberately — and a tick meaning "we assume so" is worse than no tick. The invitation to the Discord is at the very bottom, after the list. Somebody who has just installed this came with a job in mind, and opening with a social invitation is the fastest way to lose them. The marker is raised where a first administrator comes into existence — the setup screen and `projectsend:admin`, so a container provisioned from environment variables is welcomed too — and it is false by default, so an installation that updates into this feature is not congratulated on an install it finished a year ago. RedirectToWhatsNew becomes RedirectToGreeting and answers for both: they are the same interruption, and a second middleware on the same route would have to know about the first to avoid arguing with it. Installing wins; release notes for a version you never ran are the wrong greeting. Co-Authored-By: Claude Opus 5 --- INSTALL.md | 5 +- app/Http/Middleware/RedirectToGreeting.php | 62 ++++++ app/Http/Middleware/RedirectToWhatsNew.php | 43 ---- .../Identity/Console/CreateAdminCommand.php | 7 + .../Http/Controllers/SetupController.php | 7 + .../Controllers/GettingStartedController.php | 49 +++++ .../Onboarding/InstallationWelcome.php | 79 +++++++ .../Platform/Onboarding/QuickStart.php | 147 +++++++++++++ app/Modules/Platform/Settings/Setting.php | 17 +- resources/js/pages/system/about.tsx | 3 + resources/js/pages/system/getting-started.tsx | 118 ++++++++++ .../js/pages/system/settings/theming.tsx | 6 +- routes/settings.php | 9 + routes/web.php | 6 +- tests/Feature/Platform/GettingStartedTest.php | 206 ++++++++++++++++++ 15 files changed, 715 insertions(+), 49 deletions(-) create mode 100644 app/Http/Middleware/RedirectToGreeting.php delete mode 100644 app/Http/Middleware/RedirectToWhatsNew.php create mode 100644 app/Modules/Platform/Http/Controllers/GettingStartedController.php create mode 100644 app/Modules/Platform/Onboarding/InstallationWelcome.php create mode 100644 app/Modules/Platform/Onboarding/QuickStart.php create mode 100644 resources/js/pages/system/getting-started.tsx create mode 100644 tests/Feature/Platform/GettingStartedTest.php 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) => ( +
  1. + + {/* 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} + + + + +
  2. + ))} +
+ + {/* 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'); +});