Show a new installation's administrator around, once

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 <noreply@anthropic.com>
This commit is contained in:
ignacionelson
2026-08-15 19:44:22 -03:00
parent abe97a1f4d
commit 4ce6793da9
15 changed files with 715 additions and 49 deletions
+4 -1
View File
@@ -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:
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Modules\Platform\Onboarding\InstallationWelcome;
use App\Modules\Platform\Updates\UpdateWelcome;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Takes the administrator to whichever page is waiting for them: the
* getting-started list on a new installation, or the what's-new page the
* first time they arrive after an update.
*
* Attached to the dashboard alone rather than to the whole web group.
* The dashboard is where a login lands and where the sidebar's logo
* points, so it catches the arrival either way including the common
* case on a self-hosted server, where the person who ran update.sh was
* already signed in and never logs in at all. Applying it to every
* request instead would mean intercepting somebody mid-download or
* mid-upload to congratulate them, which is a worse trade than missing
* an administrator who happens to bookmark /files.
*
* One middleware for both because they are the same interruption, and a
* second one on the same route would have to know about the first to
* avoid arguing with it. Installation wins: the two cannot both be
* waiting in practice an update marker is only raised for an
* installation that already existed but if they ever were, somebody
* who has just installed this does not need release notes for a version
* they never ran.
*/
class RedirectToGreeting
{
public function __construct(
private readonly InstallationWelcome $installation,
private readonly UpdateWelcome $update,
) {}
public function handle(Request $request, Closure $next): Response
{
// GET only: a redirect swallows a POST body, and nothing that
// writes should ever be answered with a greeting.
if ($request->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);
}
}
@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Modules\Platform\Updates\UpdateWelcome;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Takes the administrator to the welcome page the first time they arrive
* after an update.
*
* Attached to the dashboard alone rather than to the whole web group.
* The dashboard is where a login lands and where the sidebar's logo
* points, so it catches the arrival either way including the common
* case on a self-hosted server, where the person who ran update.sh was
* already signed in and never logs in at all. Applying it to every
* request instead would mean intercepting somebody mid-download or
* mid-upload to congratulate them, which is a worse trade than missing
* an administrator who happens to bookmark /files.
*/
class RedirectToWhatsNew
{
public function __construct(private readonly UpdateWelcome $welcome) {}
public function handle(Request $request, Closure $next): Response
{
// GET only: a redirect swallows a POST body, and nothing that
// writes should ever be answered with a greeting.
if ($request->isMethod('GET')) {
$user = $request->user();
if ($user !== null && $this->welcome->isWaitingFor($user)) {
return redirect()->route('system.whats-new');
}
}
return $next($request);
}
}
@@ -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;
@@ -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);
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Platform\Onboarding\InstallationWelcome;
use App\Modules\Platform\Onboarding\QuickStart;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* The screen a brand-new installation opens on: thank you, then the short
* list of things worth doing first, then at the very bottom, once there
* is nothing left to do here the invitation to the Discord.
*
* That order is the point. Somebody who has just installed this has a job
* in mind, and the fastest way to lose them is to open with a social
* invitation instead of the thing they came to do. The invitation is
* still worth making; it just belongs after the work, not in front of it.
*/
class GettingStartedController extends Controller
{
public function __construct(
private readonly InstallationWelcome $welcome,
private readonly QuickStart $quickStart,
) {}
public function __invoke(Request $request): Response
{
$user = $request->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);
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Onboarding;
use App\Models\User;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
/**
* "This installation is new — has its administrator been shown around
* yet?" The install-time twin of UpdateWelcome, and deliberately shaped
* like it: one marker, one person, one time, and an address that keeps
* working afterwards.
*
* The marker is raised explicitly at the two places a first administrator
* comes into existence the setup screen and `projectsend:admin` rather
* than inferred from an empty database. Inferring it would mean deciding,
* on every request forever, whether an installation is "new"; a flag
* written once at the only moment the answer is unambiguous costs one
* boolean and cannot drift. A third provisioning path added later has to
* call raise() too, which is why it lives here rather than being copied
* into both callers.
*/
class InstallationWelcome
{
public function __construct(
private readonly Settings $settings,
private readonly StaffAccounts $staff,
) {}
/**
* Record that this installation was just installed.
*/
public function raise(): void
{
$this->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);
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Onboarding;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
/**
* The short list of things worth doing on a brand-new installation, in
* the order they make sense, filtered to what this person can actually
* do here.
*
* Two filters, and both matter. **Permission** keeps the list honest for
* anybody who is not an administrator a link to a screen that answers
* 403 is worse than no link. **Capability** keeps it honest per edition:
* on a managed installation there are no staff accounts to create, no
* mail server to point at and no scheduler to check, because somebody
* else does all three. A getting-started list that opens with three tasks
* you are not allowed to perform teaches the reader to ignore it.
*
* The two tasks that can be answered from the database are answered:
* "create your first client" and "upload a file" tick themselves. Nothing
* else is checkable without guessing a theme that was never changed is
* indistinguishable from one that was chosen deliberately and a tick
* that means "we assume so" is worse than no tick at all.
*/
class QuickStart
{
public function __construct(
private readonly CapabilityRegistry $capabilities,
private readonly PermissionChecker $permissions,
) {}
/**
* @return list<array{key: string, title: string, description: string, href: string, done: bool}>
*/
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();
}
}
+16 -1
View File
@@ -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 => [],
+3
View File
@@ -73,6 +73,9 @@ export default function About({ license, environment }: AboutProps) {
<a href={links.discord} target="_blank" rel="noreferrer" className="underline hover:no-underline">
{t('Discord')}
</a>
<Link href={route('system.getting-started')} className="underline hover:no-underline">
{t('Getting started')}
</Link>
{/* 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. */}
@@ -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<SharedData>().props;
const breadcrumbs: BreadcrumbItem[] = [{ title: t('Getting started'), href: '/system/getting-started' }];
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title={t('Getting started')} />
<div className="px-4 py-6">
<div className="mx-auto max-w-2xl space-y-10">
<div className="space-y-3 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{justInstalled ? t('Welcome to ProjectSend') : t('Getting started')}
</h1>
<p className="text-muted-foreground text-sm">
{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.')}
</p>
</div>
<ol className="space-y-3">
{items.map((item, index) => (
<li key={item.key}>
<Link
href={item.href}
className="hover:border-primary/40 hover:bg-accent/40 group flex items-start gap-4 rounded-lg border p-4 transition-colors"
>
{/* The step number becomes a tick where the
database can actually answer whether it
has been done. */}
<span
className={`flex size-7 shrink-0 items-center justify-center rounded-full text-sm font-medium ${
item.done ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
}`}
>
{item.done ? <Check className="size-4" strokeWidth={3} /> : index + 1}
</span>
<span className="min-w-0 flex-1">
<span className={`block font-medium ${item.done ? 'text-muted-foreground line-through' : ''}`}>
{item.title}
</span>
<span className="text-muted-foreground block text-sm">{item.description}</span>
</span>
<ArrowRight className="text-muted-foreground/50 group-hover:text-primary mt-0.5 size-4 shrink-0" />
</Link>
</li>
))}
</ol>
{/* 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. */}
<div className="bg-accent border-primary/20 flex flex-col items-center gap-4 rounded-lg border p-6 text-center">
<MessagesSquare className="text-primary size-8" strokeWidth={1.5} />
<div className="space-y-1">
<p className="text-accent-foreground font-medium">{t('Come and say hello')}</p>
<p className="text-accent-foreground/80 text-sm">
{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.',
)}
</p>
</div>
<Button asChild>
<a href={links.discord} target="_blank" rel="noreferrer">
{t('Join the Discord')}
</a>
</Button>
</div>
<div className="flex justify-center">
<Button asChild variant="outline">
<Link href={route('dashboard')}>
{t('Continue to the dashboard')}
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
</div>
</div>
</AppLayout>
);
}
@@ -83,7 +83,11 @@ function ThemeCard({
export default function ThemingSettings({ theme, email_theme, themes, email_themes }: ThemingSettingsProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<Tab>('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<Tab>(new URLSearchParams(window.location.search).get('tab') === 'email' ? 'email' : 'pages');
const [activatingKey, setActivatingKey] = useState<string | null>(null);
const [previewing, setPreviewing] = useState<ThemeOption | null>(null);
+9
View File
@@ -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
+3 -3
View File
@@ -1,6 +1,6 @@
<?php
use App\Http\Middleware\RedirectToWhatsNew;
use App\Http\Middleware\RedirectToGreeting;
use App\Modules\Api\Http\Controllers\ApiDashboardController;
use App\Modules\Api\Http\Controllers\ApiDocsController;
use App\Modules\Audit\Http\Controllers\ActivityLogController;
@@ -74,10 +74,10 @@ Route::get('s/{token}', [PublicShareController::class, 'show'])->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');
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Inertia\Testing\AssertableInertia;
/**
* A new installation shows its administrator around, once and the list
* it shows never points at something this edition or this person cannot
* do.
*/
beforeEach(function () {
$this->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<string> */
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');
});