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