mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-24 20:31:59 +00:00
4ce6793da9
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>
210 lines
16 KiB
PHP
210 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\Settings\PasswordController;
|
|
use App\Http\Controllers\Settings\ProfileController;
|
|
use App\Modules\Clients\Http\Controllers\ClientSettingsController;
|
|
use App\Modules\Comments\Http\Controllers\CommentSettingsController;
|
|
use App\Modules\Files\Http\Controllers\FileRetentionSettingsController;
|
|
use App\Modules\Files\Http\Controllers\UploadSettingsController;
|
|
use App\Modules\Identity\Http\Controllers\ApiTokensController;
|
|
use App\Modules\Identity\Http\Controllers\ConnectedAccountsController;
|
|
use App\Modules\Identity\Http\Controllers\LdapSettingsController;
|
|
use App\Modules\Identity\Http\Controllers\SecuritySettingsController;
|
|
use App\Modules\Identity\Http\Controllers\SocialLoginController;
|
|
use App\Modules\Identity\Http\Controllers\SocialLoginSettingsController;
|
|
use App\Modules\Identity\Http\Controllers\TwoFactorEnrollmentController;
|
|
use App\Modules\Notifications\Http\Controllers\NotificationPreferencesController;
|
|
use App\Modules\Platform\Http\Controllers\AboutController;
|
|
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;
|
|
use App\Modules\Platform\Http\Controllers\SchedulerMonitoringController;
|
|
use App\Modules\Platform\Http\Controllers\SystemSettingsController;
|
|
use App\Modules\Platform\Http\Controllers\ThemingSettingsController;
|
|
use App\Modules\Platform\Http\Controllers\WhatsNewController;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
Route::middleware('auth')->group(function () {
|
|
// Route::redirect()'s destination needs a leading slash, or Laravel
|
|
// deliberately emits a *relative* Location header (stripping the
|
|
// leading slash it would otherwise generate) instead of an absolute
|
|
// one — see Illuminate\Routing\RedirectController. A relative
|
|
// redirect resolves against the current path's directory, which
|
|
// breaks the moment the redirect lives more than one segment deep
|
|
// (e.g. system/settings -> system/settings/general would resolve to
|
|
// system/system/settings/general). Always use an absolute path here.
|
|
Route::redirect('settings', '/settings/profile');
|
|
|
|
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
|
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
|
|
Route::get('settings/delete-account', [ProfileController::class, 'deleteAccount'])->name('profile.delete-account');
|
|
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
|
|
|
Route::get('settings/password', [PasswordController::class, 'edit'])->name('password.edit');
|
|
Route::put('settings/password', [PasswordController::class, 'update'])->name('password.update');
|
|
|
|
Route::get('settings/two-factor', [TwoFactorEnrollmentController::class, 'show'])->name('two-factor.show');
|
|
|
|
// Staff and clients alike: which providers you have connected is a
|
|
// property of your account, not of your role.
|
|
Route::get('settings/connected-accounts', [ConnectedAccountsController::class, 'edit'])
|
|
->name('connected-accounts.edit');
|
|
Route::post('settings/connected-accounts/{provider}', [SocialLoginController::class, 'connect'])
|
|
->middleware('throttle:20,1,social-connect')
|
|
->name('connected-accounts.connect');
|
|
Route::delete('settings/connected-accounts/{provider}', [ConnectedAccountsController::class, 'destroy'])
|
|
->name('connected-accounts.destroy');
|
|
|
|
// Changing the state of the second factor re-proves the first one. A
|
|
// stolen session is exactly the situation 2FA exists to survive, so
|
|
// "disable 2FA" must not be reachable with nothing but that session.
|
|
// `confirm` is deliberately outside this group: it is mid-enrollment,
|
|
// already proves possession of the TOTP secret, and the enrollment it
|
|
// completes was itself password-confirmed by `store`.
|
|
Route::middleware('password.confirm')->group(function () {
|
|
Route::post('settings/two-factor', [TwoFactorEnrollmentController::class, 'store'])->name('two-factor.enable');
|
|
Route::post('settings/two-factor/recovery-codes', [TwoFactorEnrollmentController::class, 'regenerateRecoveryCodes'])
|
|
->name('two-factor.recovery-codes');
|
|
Route::delete('settings/two-factor', [TwoFactorEnrollmentController::class, 'destroy'])->name('two-factor.disable');
|
|
});
|
|
|
|
// Named bucket — see the note in routes/auth.php. A bare `throttle:`
|
|
// shares one counter with every other bare one, so confirming an
|
|
// enrolment code used to draw on the same six as re-sending a
|
|
// verification email.
|
|
Route::post('settings/two-factor/confirm', [TwoFactorEnrollmentController::class, 'confirm'])
|
|
->middleware('throttle:6,1,two-factor-confirm')->name('two-factor.confirm');
|
|
|
|
// API tokens are account-level credentials, so they live in the personal
|
|
// settings section — but staff-only, because /api/v1 is staff-only.
|
|
// Creating and revoking re-prove the password for the same reason the
|
|
// two-factor block above does: a token outlives the session that minted
|
|
// it, so a stolen session must not be enough to mint one.
|
|
Route::middleware('staff')->group(function () {
|
|
// `create` before `{token}`, or the literal segment is swallowed by
|
|
// the id parameter — the same ordering rule as files/orphans in
|
|
// routes/web.php.
|
|
Route::get('settings/api-tokens/create', [ApiTokensController::class, 'create'])->name('api-tokens.create');
|
|
Route::get('settings/api-tokens', [ApiTokensController::class, 'index'])->name('api-tokens.index');
|
|
Route::get('settings/api-tokens/{token}/edit', [ApiTokensController::class, 'edit'])->name('api-tokens.edit');
|
|
|
|
Route::middleware('password.confirm')->group(function () {
|
|
Route::post('settings/api-tokens', [ApiTokensController::class, 'store'])->name('api-tokens.store');
|
|
// Editing can widen what an already-issued secret may do, so it
|
|
// re-proves the password exactly as minting does.
|
|
Route::patch('settings/api-tokens/{token}', [ApiTokensController::class, 'update'])->name('api-tokens.update');
|
|
Route::delete('settings/api-tokens/{token}', [ApiTokensController::class, 'destroy'])->name('api-tokens.destroy');
|
|
});
|
|
});
|
|
|
|
Route::get('settings/notifications', [NotificationPreferencesController::class, 'edit'])->name('notification-preferences.edit');
|
|
Route::put('settings/notifications', [NotificationPreferencesController::class, 'update'])->name('notification-preferences.update');
|
|
|
|
// Read-only, so `staff` alone rather than the `can:edit_settings`
|
|
// group below: the release you are running, its licence and where
|
|
// its source lives are not a configuration secret, and the sidebar
|
|
// footer links every staff member here regardless of what they may
|
|
// 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
|
|
// in here performed the update it thanks them for.
|
|
Route::middleware(['staff', 'capability:system.updates', 'can:view_system_info'])
|
|
->get('system/whats-new', WhatsNewController::class)
|
|
->name('system.whats-new');
|
|
|
|
// System-wide configuration — deliberately outside the account
|
|
// settings section; it lives under its own sidebar entry, one route
|
|
// per section (general today; clients, email, … as modules land).
|
|
Route::middleware(['staff', 'can:edit_settings'])->group(function () {
|
|
Route::redirect('system/settings', '/system/settings/general');
|
|
Route::get('system/settings/general', [SystemSettingsController::class, 'edit'])->name('system-settings.edit');
|
|
Route::patch('system/settings/general', [SystemSettingsController::class, 'update'])->name('system-settings.update');
|
|
Route::get('system/settings/security', [SecuritySettingsController::class, 'edit'])->name('system-settings.security.edit');
|
|
Route::patch('system/settings/security', [SecuritySettingsController::class, 'update'])->name('system-settings.security.update');
|
|
Route::get('system/settings/clients', [ClientSettingsController::class, 'edit'])->name('system-settings.clients.edit');
|
|
Route::patch('system/settings/clients', [ClientSettingsController::class, 'update'])->name('system-settings.clients.update');
|
|
Route::get('system/settings/uploads', [UploadSettingsController::class, 'edit'])->name('system-settings.uploads.edit');
|
|
Route::patch('system/settings/uploads', [UploadSettingsController::class, 'update'])->name('system-settings.uploads.update');
|
|
Route::get('system/settings/file-retention', [FileRetentionSettingsController::class, 'edit'])->name('system-settings.file-retention.edit');
|
|
Route::patch('system/settings/file-retention', [FileRetentionSettingsController::class, 'update'])->name('system-settings.file-retention.update');
|
|
Route::get('system/settings/comments', [CommentSettingsController::class, 'edit'])->name('system-settings.comments.edit');
|
|
Route::patch('system/settings/comments', [CommentSettingsController::class, 'update'])->name('system-settings.comments.update');
|
|
Route::get('system/settings/email', [EmailSettingsController::class, 'edit'])->name('system-settings.email.edit');
|
|
Route::patch('system/settings/email', [EmailSettingsController::class, 'update'])->name('system-settings.email.update');
|
|
Route::post('system/settings/email/test', [EmailSettingsController::class, 'sendTest'])->name('system-settings.email.test');
|
|
// Deliberately outside any capability: group. LDAP is an
|
|
// administrator's setting, available in every edition, not an
|
|
// edition difference.
|
|
Route::get('system/settings/ldap', [LdapSettingsController::class, 'edit'])->name('system-settings.ldap.edit');
|
|
Route::patch('system/settings/ldap', [LdapSettingsController::class, 'update'])->name('system-settings.ldap.update');
|
|
Route::post('system/settings/ldap/test', [LdapSettingsController::class, 'test'])->name('system-settings.ldap.test');
|
|
|
|
// Outside any capability: group for the same reason as LDAP above.
|
|
Route::get('system/settings/social-login', [SocialLoginSettingsController::class, 'edit'])
|
|
->name('system-settings.social-login.edit');
|
|
Route::patch('system/settings/social-login/{provider}', [SocialLoginSettingsController::class, 'update'])
|
|
->name('system-settings.social-login.update');
|
|
|
|
// Outside any capability: group for the same reason as LDAP above.
|
|
// Only the option of using the platform's own keys is an edition
|
|
// difference, and that is gated per field inside the controller.
|
|
Route::get('system/settings/captcha', [CaptchaSettingsController::class, 'edit'])->name('system-settings.captcha.edit');
|
|
Route::patch('system/settings/captcha', [CaptchaSettingsController::class, 'update'])->name('system-settings.captcha.update');
|
|
Route::post('system/settings/captcha/test', [CaptchaSettingsController::class, 'test'])->name('system-settings.captcha.test');
|
|
|
|
Route::get('system/settings/privacy', [PrivacySettingsController::class, 'edit'])->name('system-settings.privacy.edit');
|
|
Route::patch('system/settings/privacy', [PrivacySettingsController::class, 'update'])->name('system-settings.privacy.update');
|
|
Route::get('system/settings/public-listing', [PublicListingSettingsController::class, 'edit'])->name('system-settings.public-listing.edit');
|
|
Route::patch('system/settings/public-listing', [PublicListingSettingsController::class, 'update'])->name('system-settings.public-listing.update');
|
|
Route::get('system/settings/languages', [LanguageSettingsController::class, 'edit'])->name('system-settings.languages.edit');
|
|
Route::patch('system/settings/languages', [LanguageSettingsController::class, 'update'])->name('system-settings.languages.update');
|
|
Route::get('system/settings/theming', [ThemingSettingsController::class, 'edit'])->name('system-settings.theming.edit');
|
|
Route::patch('system/settings/theming', [ThemingSettingsController::class, 'update'])->name('system-settings.theming.update');
|
|
Route::get('system/settings/theming/email-preview/{key}', [ThemingSettingsController::class, 'previewEmail'])
|
|
->name('system-settings.theming.email-preview');
|
|
|
|
// Every field on this page is Community-only (Capability::StorageConfigure)
|
|
// — unlike email settings, there's no capability-independent
|
|
// sub-field to keep serving in Cloud, so the whole surface is
|
|
// gated at the route level instead of per-field in the controller.
|
|
Route::middleware('capability:storage.configure')->group(function () {
|
|
Route::get('system/settings/storage', [ExternalStorageSettingsController::class, 'edit'])->name('system-settings.storage.edit');
|
|
Route::patch('system/settings/storage', [ExternalStorageSettingsController::class, 'update'])->name('system-settings.storage.update');
|
|
Route::post('system/settings/storage/test', [ExternalStorageSettingsController::class, 'testConnection'])->name('system-settings.storage.test');
|
|
});
|
|
|
|
// Same all-or-nothing shape as Storage above (Capability::SchedulerMonitoring).
|
|
Route::middleware('capability:scheduler.monitoring')->group(function () {
|
|
Route::get('system/settings/scheduler', [SchedulerMonitoringController::class, 'index'])->name('system-settings.scheduler.index');
|
|
Route::post('system/settings/scheduler/failed-jobs/{uuid}/retry', [SchedulerMonitoringController::class, 'retryFailedJob'])->name('system-settings.scheduler.retry');
|
|
Route::delete('system/settings/scheduler/failed-jobs/{uuid}', [SchedulerMonitoringController::class, 'destroyFailedJob'])->name('system-settings.scheduler.destroy');
|
|
Route::delete('system/settings/scheduler/failed-jobs', [SchedulerMonitoringController::class, 'destroyAllFailedJobs'])->name('system-settings.scheduler.destroy-all');
|
|
});
|
|
});
|
|
|
|
// Distinct from edit_settings, matching v1's exact permission split.
|
|
Route::middleware(['staff', 'can:edit_email_templates'])->group(function () {
|
|
Route::get('system/settings/email-templates', [EmailTemplatesController::class, 'index'])->name('email-templates.index');
|
|
Route::get('system/settings/email-templates/{slot}', [EmailTemplatesController::class, 'edit'])->name('email-templates.edit');
|
|
Route::patch('system/settings/email-templates/{slot}', [EmailTemplatesController::class, 'update'])->name('email-templates.update');
|
|
Route::delete('system/settings/email-templates/{slot}', [EmailTemplatesController::class, 'destroy'])->name('email-templates.destroy');
|
|
Route::get('system/settings/email-templates/{slot}/preview', [EmailTemplatesController::class, 'preview'])->name('email-templates.preview');
|
|
});
|
|
});
|