Compare commits

..

16 Commits

Author SHA1 Message Date
dependabot[bot]
c45d6e2f5e Bump the minor-updates group across 1 directory with 20 updates
Bumps the minor-updates group with 17 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [dedoc/scramble](https://github.com/dedoc/scramble) | `0.12.36` | `0.13.43` |
| [flowframe/laravel-trend](https://github.com/Flowframe/laravel-trend) | `0.4.0` | `0.5.0` |
| [gotenberg/gotenberg-php](https://github.com/gotenberg/gotenberg-php) | `2.21.0` | `2.25.0` |
| [laravel/fortify](https://github.com/laravel/fortify) | `1.37.2` | `1.39.0` |
| [laravel/octane](https://github.com/laravel/octane) | `2.17.4` | `2.19.1` |
| [laravel/passport](https://github.com/laravel/passport) | `13.7.5` | `13.8.0` |
| [league/flysystem-aws-s3-v3](https://github.com/thephpleague/flysystem-aws-s3-v3) | `3.34.0` | `3.35.3` |
| [league/iso3166](https://github.com/alcohol/iso3166) | `4.4.0` | `4.5.0` |
| [owen-it/laravel-auditing](https://github.com/owen-it/laravel-auditing) | `14.0.3` | `14.0.6` |
| [spatie/temporary-directory](https://github.com/spatie/temporary-directory) | `2.3.1` | `2.4.0` |
| [tightenco/ziggy](https://github.com/tighten/ziggy) | `2.6.2` | `2.6.4` |
| [fumeapp/modeltyper](https://github.com/fumeapp/modeltyper) | `3.10.0` | `3.13.0` |
| [larastan/larastan](https://github.com/larastan/larastan) | `3.9.6` | `3.12.0` |
| [laravel/pint](https://github.com/laravel/pint) | `1.29.1` | `1.32.1` |
| [laravel/sail](https://github.com/laravel/sail) | `1.60.0` | `1.67.0` |
| [laravel/telescope](https://github.com/laravel/telescope) | `5.20.0` | `5.24.0` |
| [nunomaduro/collision](https://github.com/nunomaduro/collision) | `8.9.4` | `8.9.5` |



Updates `dedoc/scramble` from 0.12.36 to 0.13.43
- [Release notes](https://github.com/dedoc/scramble/releases)
- [Commits](https://github.com/dedoc/scramble/compare/v0.12.36...0.13.43)

Updates `flowframe/laravel-trend` from 0.4.0 to 0.5.0
- [Release notes](https://github.com/Flowframe/laravel-trend/releases)
- [Changelog](https://github.com/Flowframe/laravel-trend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Flowframe/laravel-trend/compare/v0.4.0...v0.5.0)

Updates `gotenberg/gotenberg-php` from 2.21.0 to 2.25.0
- [Release notes](https://github.com/gotenberg/gotenberg-php/releases)
- [Commits](https://github.com/gotenberg/gotenberg-php/compare/v2.21.0...v2.25.0)

Updates `guzzlehttp/guzzle` from 7.10.3 to 7.15.5
- [Release notes](https://github.com/guzzle/guzzle/releases)
- [Changelog](https://github.com/guzzle/guzzle/blob/7.15.5/CHANGELOG.md)
- [Commits](https://github.com/guzzle/guzzle/compare/7.10.3...7.15.5)

Updates `laravel/fortify` from 1.37.2 to 1.39.0
- [Release notes](https://github.com/laravel/fortify/releases)
- [Changelog](https://github.com/laravel/fortify/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/fortify/compare/v1.37.2...v1.39.0)

Updates `laravel/framework` from 12.60.2 to 12.69.2
- [Release notes](https://github.com/laravel/framework/releases)
- [Changelog](https://github.com/laravel/framework/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/framework/compare/v12.60.2...v12.69.2)

Updates `laravel/octane` from 2.17.4 to 2.19.1
- [Release notes](https://github.com/laravel/octane/releases)
- [Changelog](https://github.com/laravel/octane/blob/2.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/octane/compare/v2.17.4...v2.19.1)

Updates `laravel/passport` from 13.7.5 to 13.8.0
- [Release notes](https://github.com/laravel/passport/releases)
- [Changelog](https://github.com/laravel/passport/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/passport/compare/v13.7.5...v13.8.0)

Updates `league/flysystem-aws-s3-v3` from 3.34.0 to 3.35.3
- [Commits](https://github.com/thephpleague/flysystem-aws-s3-v3/compare/3.34.0...3.35.3)

Updates `league/iso3166` from 4.4.0 to 4.5.0
- [Release notes](https://github.com/alcohol/iso3166/releases)
- [Changelog](https://github.com/alcohol/iso3166/blob/main/CHANGELOG.md)
- [Commits](https://github.com/alcohol/iso3166/compare/4.4.0...4.5.0)

Updates `owen-it/laravel-auditing` from 14.0.3 to 14.0.6
- [Release notes](https://github.com/owen-it/laravel-auditing/releases)
- [Changelog](https://github.com/owen-it/laravel-auditing/blob/master/CHANGELOG.md)
- [Commits](https://github.com/owen-it/laravel-auditing/compare/v14.0.3...v14.0.6)

Updates `spatie/temporary-directory` from 2.3.1 to 2.4.0
- [Release notes](https://github.com/spatie/temporary-directory/releases)
- [Changelog](https://github.com/spatie/temporary-directory/blob/main/CHANGELOG.md)
- [Commits](https://github.com/spatie/temporary-directory/compare/2.3.1...2.4.0)

Updates `tightenco/ziggy` from 2.6.2 to 2.6.4
- [Release notes](https://github.com/tighten/ziggy/releases)
- [Changelog](https://github.com/tighten/ziggy/blob/2.x/CHANGELOG.md)
- [Commits](https://github.com/tighten/ziggy/compare/v2.6.2...v2.6.4)

Updates `fumeapp/modeltyper` from 3.10.0 to 3.13.0
- [Release notes](https://github.com/fumeapp/modeltyper/releases)
- [Commits](https://github.com/fumeapp/modeltyper/compare/v3.10.0...v3.13.0)

Updates `larastan/larastan` from 3.9.6 to 3.12.0
- [Release notes](https://github.com/larastan/larastan/releases)
- [Changelog](https://github.com/larastan/larastan/blob/3.x/RELEASE.md)
- [Commits](https://github.com/larastan/larastan/compare/v3.9.6...v3.12.0)

Updates `laravel/pint` from 1.29.1 to 1.32.1
- [Release notes](https://github.com/laravel/pint/releases)
- [Changelog](https://github.com/laravel/pint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/laravel/pint/compare/v1.29.1...v1.32.1)

Updates `laravel/sail` from 1.60.0 to 1.67.0
- [Release notes](https://github.com/laravel/sail/releases)
- [Changelog](https://github.com/laravel/sail/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/sail/compare/v1.60.0...v1.67.0)

Updates `laravel/telescope` from 5.20.0 to 5.24.0
- [Release notes](https://github.com/laravel/telescope/releases)
- [Changelog](https://github.com/laravel/telescope/blob/5.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/telescope/compare/v5.20.0...v5.24.0)

Updates `mockery/mockery` from 1.6.12 to 1.6.15
- [Release notes](https://github.com/mockery/mockery/releases)
- [Changelog](https://github.com/mockery/mockery/blob/1.6.x/CHANGELOG.md)
- [Commits](https://github.com/mockery/mockery/compare/1.6.12...1.6.15)

Updates `nunomaduro/collision` from 8.9.4 to 8.9.5
- [Commits](https://github.com/nunomaduro/collision/compare/v8.9.4...v8.9.5)

---
updated-dependencies:
- dependency-name: dedoc/scramble
  dependency-version: 0.13.43
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: flowframe/laravel-trend
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: gotenberg/gotenberg-php
  dependency-version: 2.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: guzzlehttp/guzzle
  dependency-version: 7.15.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/fortify
  dependency-version: 1.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/framework
  dependency-version: 12.69.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/octane
  dependency-version: 2.19.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/passport
  dependency-version: 13.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: league/flysystem-aws-s3-v3
  dependency-version: 3.35.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: league/iso3166
  dependency-version: 4.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: owen-it/laravel-auditing
  dependency-version: 14.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: spatie/temporary-directory
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: tightenco/ziggy
  dependency-version: 2.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: fumeapp/modeltyper
  dependency-version: 3.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: larastan/larastan
  dependency-version: 3.12.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/pint
  dependency-version: 1.32.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/sail
  dependency-version: 1.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/telescope
  dependency-version: 5.24.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: mockery/mockery
  dependency-version: 1.6.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: nunomaduro/collision
  dependency-version: 8.9.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 08:06:58 +00:00
Gregor Vostrak
5b12c09747 bump invoicing extension to v0.0.6 for pagination ui package change 2026-09-07 17:13:20 +02:00
Gregor Vostrak
24023353f2 bump ui package version 2026-09-07 16:40:55 +02:00
Gregor Vostrak
720d20c10e move pagination component to ui package 2026-09-07 16:40:55 +02:00
Gregor Vostrak
82ea9af8b5 fix radix dialog focus restore behaviour 2026-09-04 14:55:09 +02:00
Gregor Vostrak
169d522da0 fix activity graph border color 2026-09-03 17:15:19 +02:00
Gregor Vostrak
45c7377802 bump invoicing extension to v0.0.5 2026-08-31 16:38:25 +02:00
Gregor Vostrak
8e57275cef bump invoicing extension version 2026-08-31 16:03:26 +02:00
Gregor Vostrak
efc6b55628 add helper functions for tanstack form and cent conversions 2026-08-31 16:03:26 +02:00
Gregor Vostrak
c12789376d fix type error and adapt formatting to new prettier version changes 2026-08-31 16:03:26 +02:00
Gregor Vostrak
4795812b60 align alter dialog positioning with other modals 2026-08-31 16:03:26 +02:00
Gregor Vostrak
0e00979ab8 make sure all invoices routes show active state in the navigation 2026-08-31 16:03:26 +02:00
Gregor Vostrak
f1426fcb5e add combobox to ui package 2026-08-31 16:03:26 +02:00
Constantin Graf
23f512d4a4 Add permissions and types for invoice recipients 2026-08-31 16:03:26 +02:00
Constantin Graf
637475e669 Add invite-only registration mode 2026-08-31 13:36:25 +02:00
Constantin Graf
3ec2abb309 Add invite-only registration mode
Support configurable on, invite-only, and off registration modes, including case-insensitive invitation checks and test coverage.
2026-08-31 13:36:25 +02:00
28 changed files with 1150 additions and 715 deletions

View File

@@ -5,7 +5,8 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true
APP_URL=https://solidtime.test
APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true
# Supported values: on, off, invite-only (true/false are supported for backwards compatibility)
APP_ENABLE_REGISTRATION=on
SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Enums\RegistrationMode;
use App\Enums\Weekday;
use App\Events\NewsletterRegistered;
use App\Models\User;
use App\Service\InvitationService;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService;
use App\Service\UserService;
@@ -31,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers
*/
public function create(array $input): User
{
if (! config('app.enable_registration')) {
$registrationMode = RegistrationMode::fromConfig(config('app.enable_registration'));
if ($registrationMode === RegistrationMode::Off) {
throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')],
]);
}
Validator::make($input, [
$validated = Validator::make($input, [
'name' => [
'required',
'string',
@@ -60,6 +63,20 @@ class CreateNewUser implements CreatesNewUsers
],
])->validate();
if ($registrationMode === RegistrationMode::InviteOnly) {
$invitationService = app(InvitationService::class);
$email = (string) $validated['email'];
if (! $invitationService->hasAcceptedInvitationForEmail($email)) {
$message = $invitationService->hasPendingInvitationForEmail($email)
? __('Please accept the organization invitation sent to your email address before registering.')
: __('Registration is only available to invited users.');
throw ValidationException::withMessages([
'email' => [$message],
]);
}
}
$timezone = null;
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
if (app(TimezoneService::class)->isValid($input['timezone'])) {

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum RegistrationMode: string
{
case On = 'on';
case InviteOnly = 'invite-only';
case Off = 'off';
public static function fromConfig(mixed $value): self
{
if ($value === true) {
return self::On;
}
if ($value === false || $value === null) {
return self::Off;
}
return match (strtolower(trim((string) $value))) {
'1', 'on', 'true' => self::On,
'invite-only' => self::InviteOnly,
default => self::Off,
};
}
}

View File

@@ -36,6 +36,7 @@ class OrganizationInvitationController extends Controller
}
return redirect(route('register'))
->with('registration_email', $email)
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
'organization' => $organization->name,
]))

View File

@@ -45,6 +45,7 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::registerView(function () {
return Inertia::render('Auth/Register', [
'email' => session('registration_email', ''),
'terms_url' => config('auth.terms_url'),
'privacy_policy_url' => config('auth.privacy_policy_url'),
'newsletter_consent' => config('auth.newsletter_consent'),

View File

@@ -18,6 +18,22 @@ use Illuminate\Support\Facades\Mail;
class InvitationService
{
public function hasAcceptedInvitationForEmail(string $email): bool
{
return OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($email)])
->whereNotNull('accepted_at')
->exists();
}
public function hasPendingInvitationForEmail(string $email): bool
{
return OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($email)])
->whereNull('accepted_at')
->exists();
}
/**
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
*/

View File

@@ -9,9 +9,9 @@
"ext-zip": "*",
"brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.12.2",
"dedoc/scramble": "^0.13.43",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.4.0",
"flowframe/laravel-trend": "^0.5.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^2.0.3",

1461
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -100,7 +100,7 @@ return [
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
'enable_registration' => env('APP_ENABLE_REGISTRATION', 'off'),
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),

View File

@@ -0,0 +1,32 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { getInvitationAcceptUrl } from './utils/mailpit';
test('prefills the registration email after accepting an invitation', async ({ page, browser }) => {
const memberId = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const memberEmail = `prefill-${memberId}@invitation.test`;
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
await page.getByRole('button', { name: 'Invite Member' }).click();
await page.getByPlaceholder('Member Email').fill(memberEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
const inviteeContext = await browser.newContext();
const inviteePage = await inviteeContext.newPage();
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
await inviteePage.goto(acceptUrl);
await inviteePage.waitForURL(/\/register$/);
await expect(inviteePage.getByLabel('Email')).toHaveValue(memberEmail);
await inviteeContext.close();
});

View File

@@ -9,6 +9,6 @@
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.4"
"ref": "v0.0.6"
}
}

2
package-lock.json generated
View File

@@ -8564,7 +8564,7 @@
},
"resources/js/packages/ui": {
"name": "@solidtime/ui",
"version": "0.0.22",
"version": "0.0.23",
"license": "AGPL-3.0",
"devDependencies": {
"@types/chroma-js": "^3.1.2",

View File

@@ -7,7 +7,7 @@ import { type Client } from '@/packages/api/src';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
import Pagination from '@/Components/Common/Pagination.vue';
import Pagination from '@/packages/ui/src/Pagination.vue';
import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import {

View File

@@ -6,7 +6,7 @@ import { computed, ref, watch } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import Pagination from '@/Components/Common/Pagination.vue';
import Pagination from '@/packages/ui/src/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn =

View File

@@ -63,7 +63,7 @@ const max = computed(() => {
});
const backgroundColor = useCssVariable('--theme-color-card-background');
const borderColor = useCssVariable('--color-border');
const borderColor = useCssVariable('--color-border-secondary');
const labelColor = useCssVariable('--color-text-secondary');
const chartColorRaw = useCssVariable('--theme-color-chart');

View File

@@ -8,9 +8,13 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
const props = defineProps<{
email: string;
}>();
const form = useForm({
name: '',
email: '',
email: props.email,
password: '',
password_confirmation: '',
terms: false,

View File

@@ -9,7 +9,7 @@ import {
ArrowDownTrayIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid';
import Pagination from '@/Components/Common/Pagination.vue';
import Pagination from '@/packages/ui/src/Pagination.vue';
import {
DropdownMenu,
DropdownMenuContent,

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/ui",
"version": "0.0.22",
"version": "0.0.23",
"description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js",

View File

@@ -11,6 +11,7 @@ import {
CommandShortcut,
} from '../command';
import { cn } from '../utils/cn';
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
import type {
CommandPaletteCommand,
CommandPaletteGroup,
@@ -36,6 +37,8 @@ const emit = defineEmits<{
select: [command: CommandPaletteCommand | EntitySearchResult];
}>();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
// Non-empty groups for rendering
const nonEmptyGroups = computed(() => props.groups.filter((g) => g.commands.length > 0));
@@ -71,7 +74,9 @@ watch(open, (isOpen) => {
)
">
<DialogContent
class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus">
<CommandRoot
v-model:search-term="searchTerm"
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">

View File

@@ -16,8 +16,8 @@ import {
ChevronRightIcon,
EllipsisHorizontalIcon,
} from '@heroicons/vue/20/solid';
import { buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from './Buttons/index';
import { cn } from './utils/cn';
import { computed, watch } from 'vue';
const page = defineModel<number>('page', { default: 1 });

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
import {
DialogContent,
type DialogContentEmits,
@@ -20,6 +21,10 @@ const delegatedProps = computed(() => {
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
// Forwarded consumer listeners run first, so a consumer can still take over
// by calling preventDefault() on close-auto-focus.
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
</script>
<template>
@@ -36,7 +41,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class
)
">
"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus">
<slot />
</DialogContent>
</div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
import { X } from '@lucide/vue';
import {
DialogClose,
@@ -22,6 +23,10 @@ const delegatedProps = computed(() => {
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
// Forwarded consumer listeners run first, so a consumer can still take over
// by calling preventDefault() on close-auto-focus.
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
</script>
<template>
@@ -36,6 +41,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
)
"
v-bind="forwarded"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;

View File

@@ -32,6 +32,7 @@ import InputLabel from './Input/InputLabel.vue';
import TextInput from './Input/TextInput.vue';
import LoadingSpinner from './LoadingSpinner.vue';
import Modal from './Modal.vue';
import Pagination from './Pagination.vue';
import ProjectBadge from './Project/ProjectBadge.vue';
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
@@ -257,6 +258,7 @@ export {
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
Pagination,
Popover,
PopoverAnchor,
PopoverContent,

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import { useDialogFocusRestore } from './useDialogFocusRestore';
function closeEvent() {
return new CustomEvent('focusScope.autoFocusOnUnmount', { cancelable: true });
}
describe('useDialogFocusRestore', () => {
it('restores focus to the element focused when the dialog opened', () => {
vi.useFakeTimers();
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
onOpenAutoFocus();
button.blur();
const event = closeEvent();
onCloseAutoFocus(event);
expect(event.defaultPrevented).toBe(true);
vi.runAllTimers();
expect(document.activeElement).toBe(button);
button.remove();
vi.useRealTimers();
});
it('focuses nothing when the dialog was opened with nothing focused', () => {
vi.useFakeTimers();
const stale = document.createElement('button');
document.body.appendChild(stale);
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
// First open from the button, then close
stale.focus();
onOpenAutoFocus();
onCloseAutoFocus(closeEvent());
vi.runAllTimers();
stale.blur();
// Second open from the body must not refocus the stale button
onOpenAutoFocus();
const event = closeEvent();
onCloseAutoFocus(event);
expect(event.defaultPrevented).toBe(true);
vi.runAllTimers();
expect(document.activeElement).toBe(document.body);
stale.remove();
vi.useRealTimers();
});
it('does not restore focus to an element that was removed', () => {
vi.useFakeTimers();
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
onOpenAutoFocus();
button.remove();
onCloseAutoFocus(closeEvent());
vi.runAllTimers();
expect(document.activeElement).toBe(document.body);
vi.useRealTimers();
});
it('leaves control to a consumer that already prevented the event', () => {
vi.useFakeTimers();
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
onOpenAutoFocus();
button.blur();
const event = closeEvent();
event.preventDefault();
onCloseAutoFocus(event);
vi.runAllTimers();
expect(document.activeElement).toBe(document.body);
button.remove();
vi.useRealTimers();
});
});

View File

@@ -0,0 +1,41 @@
/**
* Restores focus to the element that was focused when a dialog opened.
*
* reka-ui remembers the active element at content mount as the dialog's
* "trigger" (only when it is not the body) and refocuses it on every close,
* but it never clears that value. A dialog opened while nothing is focused
* (e.g. the command palette via Cmd+K from the body) therefore refocuses
* whatever triggered a *previous* open. Bind these handlers to
* `DialogContent`'s `open-auto-focus` / `close-auto-focus` events to restore
* exactly the previously focused element, or nothing.
*
* A consumer handler that already called `preventDefault()` on
* `close-auto-focus` keeps control; this composable then does nothing.
*/
export function useDialogFocusRestore() {
let previouslyFocused: HTMLElement | null = null;
function onOpenAutoFocus() {
const active = document.activeElement;
previouslyFocused =
active instanceof HTMLElement && active !== document.body ? active : null;
}
function onCloseAutoFocus(event: Event) {
const target = previouslyFocused;
previouslyFocused = null;
if (event.defaultPrevented) {
return;
}
// Prevents both FocusScope's default restore and reka-ui's trigger refocus
event.preventDefault();
// Same tick reka-ui uses, so the dialog content is fully gone first
setTimeout(() => {
if (target?.isConnected) {
target.focus({ preventScroll: true });
}
}, 0);
}
return { onOpenAutoFocus, onCloseAutoFocus };
}

View File

@@ -22,6 +22,13 @@ use TiMacDonald\Log\LogEntry;
class RegistrationTest extends TestCaseWithDatabase
{
protected function setUp(): void
{
parent::setUp();
Config::set('app.enable_registration', 'on');
}
public function test_registration_screen_can_be_rendered(): void
{
if (! Features::enabled(Features::registration())) {
@@ -89,6 +96,77 @@ class RegistrationTest extends TestCaseWithDatabase
Event::assertNotDispatched(NewsletterRegistered::class);
}
public function test_user_registration_fails_without_an_invitation_if_registration_is_invite_only(): void
{
Config::set('app.enable_registration', 'invite-only');
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => true,
]);
$response->assertInvalid([
'email' => 'Registration is only available to invited users.',
]);
$this->assertFalse(User::query()->where('email', 'test@example.com')->exists());
}
public function test_invited_user_can_register_if_registration_is_invite_only(): void
{
Config::set('app.enable_registration', 'invite-only');
$user = $this->createUserWithPermission();
OrganizationInvitation::factory()
->forOrganization($user->organization)
->role(Role::Employee)
->accepted()
->create([
'email' => 'Invited.User@example.com',
]);
$response = $this->post('/register', [
'name' => 'Invited User',
'email' => 'invited.user@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => true,
]);
$response->assertValid();
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
$newUser = User::query()->where('email', 'invited.user@example.com')->firstOrFail();
$this->assertSame($user->organization->getKey(), $newUser->organizations()->firstOrFail()->getKey());
}
public function test_user_must_accept_pending_invitation_before_registration_if_registration_is_invite_only(): void
{
Config::set('app.enable_registration', 'invite-only');
$user = $this->createUserWithPermission();
OrganizationInvitation::factory()
->forOrganization($user->organization)
->role(Role::Employee)
->create([
'email' => 'test@example.com',
]);
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => true,
]);
$response->assertInvalid([
'email' => 'Please accept the organization invitation sent to your email address before registering.',
]);
$this->assertGuest();
$this->assertFalse(User::query()->where('email', 'test@example.com')->exists());
}
public function test_new_user_can_not_register_with_likely_invalid_domain(): void
{
// Act

View File

@@ -38,6 +38,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
$response->assertRedirect(route('register'));
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'info');
$response->assertSessionHas('registration_email', strtolower($invitation->email));
$invitation->refresh();
$this->assertNotNull($invitation->accepted_at);
}
@@ -64,6 +65,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
$response->assertRedirect(route('register'));
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
$response->assertSessionHas('bannerStyle', 'info');
$response->assertSessionHas('registration_email', strtolower($invitation->email));
$invitation->refresh();
$this->assertNotNull($invitation->accepted_at);
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Enums;
use App\Enums\RegistrationMode;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(RegistrationMode::class)]
class RegistrationModeTest extends TestCase
{
/**
* @return array<string, array{mixed, RegistrationMode}>
*/
public static function configValueProvider(): array
{
return [
'boolean true' => [true, RegistrationMode::On],
'on' => ['on', RegistrationMode::On],
'true' => ['true', RegistrationMode::On],
'invite-only' => ['invite-only', RegistrationMode::InviteOnly],
'boolean false' => [false, RegistrationMode::Off],
'off' => ['off', RegistrationMode::Off],
'false' => ['false', RegistrationMode::Off],
'unsupported invite alias' => ['invite', RegistrationMode::Off],
'unknown' => ['unknown', RegistrationMode::Off],
];
}
#[DataProvider('configValueProvider')]
public function test_registration_mode_is_created_from_config_value(mixed $value, RegistrationMode $expected): void
{
$this->assertSame($expected, RegistrationMode::fromConfig($value));
}
}