Compare commits

...

8 Commits

Author SHA1 Message Date
Gregor Vostrak
dad686d107 add pending email to UserResource and update openapi client 2026-05-26 18:02:15 +02:00
Gregor Vostrak
414b5d3294 update ui package dependencies; update lucide imports 2026-05-26 17:30:30 +02:00
Gregor Vostrak
e9217df338 add user endpoint tests for idempotence email update, unauthenticated
update and invalid email
2026-05-26 17:21:40 +02:00
Gregor Vostrak
96a0c21b5e update npm dependencies 2026-05-26 17:19:42 +02:00
Gregor Vostrak
8e7c8a1e1b add profile page e2e tests 2026-05-26 17:11:28 +02:00
Gregor Vostrak
6299e242a9 update email address change info to use session based banners 2026-05-26 14:03:30 +02:00
Gregor Vostrak
c573d31ef9 add 1MB photo upload limit 2026-05-26 13:59:44 +02:00
Gregor Vostrak
00ffabe108 add photo delete logic to user update endpoint 2026-05-26 13:23:31 +02:00
55 changed files with 1592 additions and 1071 deletions

View File

@@ -49,18 +49,23 @@ class UserController extends Controller
throw new AuthorizationException;
}
if ($request->getPhoto() !== null) {
$photo = Base64File::decode($request->getPhoto());
assert($photo !== null);
$extension = Base64File::extension($photo['mime_type']);
assert($extension !== null);
$previousPhotoPath = $user->profile_photo_path;
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
if ($request->hasPhotoKey()) {
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
$previousPhotoPath = $user->profile_photo_path;
$newPhoto = $request->getPhoto();
Storage::disk($photoDisk)->put($photoPath, $photo['data'], 'public');
$user->profile_photo_path = $photoPath;
if ($newPhoto === null) {
$user->profile_photo_path = null;
} else {
$decoded = Base64File::decode($newPhoto);
assert($decoded !== null);
$extension = Base64File::extension($decoded['mime_type']);
assert($extension !== null);
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
Storage::disk($photoDisk)->put($photoPath, $decoded['data'], 'public');
$user->profile_photo_path = $photoPath;
}
if ($previousPhotoPath !== null) {
Storage::disk($photoDisk)->delete($previousPhotoPath);

View File

@@ -36,10 +36,9 @@ class UserController extends Controller
->exists();
if ($emailAlreadyInUse) {
return redirect(route('dashboard', [
'bannerStyle' => 'danger',
'bannerText' => __('The email address is already in use.'),
]));
return redirect(route('dashboard'))
->with('bannerStyle', 'danger')
->with('bannerText', __('The email address is already in use.'));
}
$user->email = $email;
@@ -47,9 +46,8 @@ class UserController extends Controller
$user->email_verified_at = Carbon::now();
$user->save();
return redirect(route('dashboard', [
'bannerStyle' => 'success',
'bannerText' => __('Your email address has been updated successfully.'),
]));
return redirect(route('dashboard'))
->with('bannerStyle', 'success')
->with('bannerText', __('Your email address has been updated successfully.'));
}
}

View File

@@ -81,8 +81,15 @@ class UserUpdateRequest extends BaseFormRequest
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
}
public function hasPhotoKey(): bool
{
return $this->has('photo');
}
public function getPhoto(): ?string
{
return $this->has('photo') ? (string) $this->input('photo') : null;
$value = $this->input('photo');
return is_string($value) ? $value : null;
}
}

View File

@@ -28,6 +28,8 @@ class UserResource extends BaseResource
'name' => $this->resource->name,
/** @var string $email Email of user */
'email' => $this->resource->email,
/** @var string|null $pending_email Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
'pending_email' => $this->resource->pending_email,
/** @var string $profile_photo_url Profile photo URL */
'profile_photo_url' => $this->resource->profile_photo_url,
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */

View File

@@ -16,6 +16,8 @@ class Base64ImageRule implements ValidationRule
'image/png',
];
private const int MAX_BYTES = 1024 * 1024;
/**
* Run the validation rule.
*
@@ -32,6 +34,12 @@ class Base64ImageRule implements ValidationRule
$file = Base64File::decode($value);
if ($file === null || ! in_array($file['mime_type'], self::ALLOWED_MIME_TYPES, true)) {
$fail(__('validation.mimes', ['values' => 'jpg, png']));
return;
}
if (strlen($file['data']) > self::MAX_BYTES) {
$fail(__('validation.max.file', ['max' => (string) (self::MAX_BYTES / 1024)]));
}
}
}

View File

@@ -28,6 +28,7 @@ class UserFactory extends Factory
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'pending_email' => null,
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'two_factor_secret' => null,

View File

@@ -1,30 +1,187 @@
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
import {
countEmailsWithSubject,
getEmailChangeVerificationUrl,
waitForEmailCount,
} from './utils/mailpit';
import { getCurrentUserViaApi } from './utils/api';
import { registerUser } from './utils/members';
import type { Page } from '@playwright/test';
async function goToProfilePage(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
}
test('test that user name can be updated', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await page.getByLabel('Name', { exact: true }).fill('NEW NAME');
async function saveProfileForm(page: Page): Promise<void> {
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/user/profile-information') &&
resp.request().method() === 'POST'
),
page.getByRole('button', { name: 'Save' }).first().click(),
page.waitForResponse('**/user/profile-information'),
]);
}
test('user name can be updated', async ({ page }) => {
await goToProfilePage(page);
await page.getByLabel('Name', { exact: true }).fill('NEW NAME');
await saveProfileForm(page);
await page.reload();
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
});
test.skip('test that user email can be updated', async ({ page }) => {
// this does not work because of email verification currently
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
const emailId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
await page.getByRole('button', { name: 'Save' }).first().click();
test('timezone change persists across reload', async ({ page }) => {
await goToProfilePage(page);
await page.getByLabel('Timezone').selectOption('America/New_York');
await saveProfileForm(page);
await page.reload();
await expect(page.getByLabel('Email')).toHaveValue(`newemail+${emailId}@test.com`);
await expect(page.getByLabel('Timezone')).toHaveValue('America/New_York');
});
test('week-start change persists across reload', async ({ page }) => {
await goToProfilePage(page);
await page.getByLabel('Start of the week').selectOption('sunday');
await saveProfileForm(page);
await page.reload();
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
});
test('submitting a new email keeps the current email displayed after reload', async ({
page,
ctx,
}) => {
const { email: oldEmail } = await getCurrentUserViaApi(ctx);
const newEmail = `newemail+${Date.now()}@test.com`;
await goToProfilePage(page);
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
await page.reload();
await expect(page.getByLabel('Email')).toHaveValue(oldEmail);
});
test('submitting a new email sends a verification email to the new address', async ({
page,
request,
}) => {
await goToProfilePage(page);
const newEmail = `newemail+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
expect(await waitForEmailCount(request, newEmail, 'Verify Email Address', 1)).toBeGreaterThan(
0
);
});
test('mixed-case email is lower-cased before the verification mail is sent', async ({
page,
request,
}) => {
await goToProfilePage(page);
const stamp = Date.now();
const mixedCase = `MixedCase+${stamp}@Example.COM`;
const lowerCased = `mixedcase+${stamp}@example.com`;
await page.getByLabel('Email').fill(mixedCase);
await saveProfileForm(page);
const verifyUrl = await getEmailChangeVerificationUrl(request, lowerCased);
expect(new URL(verifyUrl).searchParams.get('email')).toBe(lowerCased);
});
test('re-submitting the current email does not send a verification email', async ({
page,
ctx,
request,
}) => {
const { email: currentEmail } = await getCurrentUserViaApi(ctx);
const beforeCount = await countEmailsWithSubject(request, currentEmail, 'Verify Email Address');
await goToProfilePage(page);
await page.getByLabel('Email').fill(currentEmail);
await saveProfileForm(page);
await new Promise((r) => setTimeout(r, 1000));
const afterCount = await countEmailsWithSubject(request, currentEmail, 'Verify Email Address');
expect(afterCount).toBe(beforeCount);
});
test('clicking the verification link swaps the email and shows a success banner', async ({
page,
}) => {
await goToProfilePage(page);
const newEmail = `verify+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail);
await page.goto(verifyUrl);
await page.waitForURL(/\/dashboard/);
const banner = page.getByTestId('banner');
await expect(banner).toBeVisible();
await expect(banner).toContainText('Your email address has been updated successfully.');
await goToProfilePage(page);
await expect(page.getByLabel('Email')).toHaveValue(newEmail);
});
test('visiting another users verification link is forbidden', async ({ page, browser }) => {
await goToProfilePage(page);
const newEmail = `victim+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail);
const other = await registerUser(browser, 'Other User', `other+${Date.now()}@test.com`);
try {
const response = await other.page.goto(verifyUrl);
expect(response?.status()).toBe(403);
} finally {
await other.close();
}
});
test('a stale verification link from a previous submission is rejected', async ({ page }) => {
await goToProfilePage(page);
const stamp = Date.now();
const olderEmail = `older+${stamp}@test.com`;
const newerEmail = `newer+${stamp}@test.com`;
await page.getByLabel('Email').fill(olderEmail);
await saveProfileForm(page);
const staleUrl = await getEmailChangeVerificationUrl(page.request, olderEmail);
await page.getByLabel('Email').fill(newerEmail);
await saveProfileForm(page);
const response = await page.goto(staleUrl);
expect(response?.status()).toBe(403);
});
test('visiting the verification link while logged out redirects to login', async ({
page,
browser,
}) => {
await goToProfilePage(page);
const newEmail = `loggedout+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail);
const anonContext = await browser.newContext();
try {
const anonPage = await anonContext.newPage();
await anonPage.goto(verifyUrl);
await anonPage.waitForURL(/\/login/);
} finally {
await anonContext.close();
}
});
async function createNewApiToken(page) {

View File

@@ -649,6 +649,19 @@ export async function createTimeEntryWithTimestampsViaApi(
// User profile helpers
// ──────────────────────────────────────────────────
export async function getCurrentUserViaApi(ctx: TestContext) {
const response = await ctx.request.get(`${PLAYWRIGHT_BASE_URL}/api/v1/users/me`);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as {
id: string;
name: string;
email: string;
timezone: string;
week_start: string;
};
}
export async function updateUserProfileViaWeb(
page: Page,
settings: { timezone?: string; week_start?: string }

View File

@@ -81,3 +81,64 @@ export async function getPasswordResetUrl(
return resetUrlMatch![1].replace(/&amp;/g, '&');
}
/**
* Count emails matching the given subject sent to the given address.
*/
export async function countEmailsWithSubject(
request: APIRequestContext,
recipientEmail: string,
subject: string
): Promise<number> {
const searchResult = await searchEmails(
request,
`to:${encodeURIComponent(recipientEmail)} subject:"${subject}"`
);
return searchResult.messages.length;
}
/**
* Poll Mailpit until the count of matching emails reaches `min`, or 5 attempts
* (~2.5s) elapse. Returns the final count.
*/
export async function waitForEmailCount(
request: APIRequestContext,
recipientEmail: string,
subject: string,
min: number
): Promise<number> {
let count = 0;
for (let attempt = 0; attempt < 5; attempt++) {
count = await countEmailsWithSubject(request, recipientEmail, subject);
if (count >= min) break;
await new Promise((r) => setTimeout(r, 500));
}
return count;
}
/**
* Find the email-change verification URL from a Mailpit email sent to the given address.
* Retries a few times to allow for email delivery delay.
*/
export async function getEmailChangeVerificationUrl(
request: APIRequestContext,
recipientEmail: string
): Promise<string> {
let searchResult: { messages: Array<{ ID: string }> } = { messages: [] };
for (let attempt = 0; attempt < 5; attempt++) {
searchResult = await searchEmails(
request,
`to:${encodeURIComponent(recipientEmail)} subject:"Verify Email Address"`
);
if (searchResult.messages.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 500));
}
expect(searchResult.messages.length).toBeGreaterThan(0);
const message = await getMessage(request, searchResult.messages[0].ID);
const verifyUrlMatch = message.HTML.match(/href="([^"]*verify-email-change[^"]*)"/);
expect(verifyUrlMatch).toBeTruthy();
return verifyUrlMatch![1].replace(/&amp;/g, '&');
}

1901
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,61 +18,62 @@
"format:check": "prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@inertiajs/vue3": "^2.0.0",
"@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/chroma-js": "^3.1.0",
"@types/node": "^22.10.10",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/tsconfig": "^0.8.0",
"autoprefixer": "^10.4.20",
"axios": "^1.6.4",
"eslint-plugin-unused-imports": "^4.1.4",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4",
"@inertiajs/vue3": "^2.3.23",
"@playwright/test": "^1.60.0",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@types/chroma-js": "^3.1.2",
"@types/node": "^22.19.19",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.5.0",
"axios": "^1.16.0",
"eslint-plugin-unused-imports": "^4.4.1",
"laravel-vite-plugin": "^2.1.0",
"openapi-zod-client": "^1.16.2",
"postcss": "^8.4.47",
"openapi-zod-client": "^1.18.3",
"postcss": "^8.5.14",
"postcss-import": "^15.1.0",
"postcss-nesting": "^12.1.5",
"tailwindcss": "^3.4.13",
"typescript": "^5.7.3",
"vite": "^7.0.0",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.3.3",
"vite-plugin-checker": "^0.12.0",
"vue": "^3.5.0",
"vue-tsc": "^3.0.0"
"vue": "^3.5.34",
"vue-tsc": "^3.2.8"
},
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6",
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5",
"@floating-ui/core": "^1.7.5",
"@floating-ui/vue": "^1.1.11",
"@heroicons/vue": "^2.2.0",
"@lucide/vue": "^1.14.0",
"@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.3.1",
"@tanstack/vue-query": "^5.56.2",
"@tanstack/vue-query-devtools": "^5.58.0",
"@tanstack/vue-table": "^8.21.2",
"@tanstack/vue-form": "^1.32.0",
"@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^5.91.0",
"@tanstack/vue-table": "^8.21.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.0.0",
"@vue/eslint-config-typescript": "^14.7.0",
"@vueuse/core": "^14.3.0",
"@vueuse/integrations": "^14.3.0",
"@zodios/core": "^10.9.6",
"chroma-js": "3.1.2",
"chroma-js": "^3.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"focus-trap": "^8.0.0",
"lucide-vue-next": "^0.487.0",
"parse-duration": "^2.0.1",
"pinia": "^3.0.0",
"radix-vue": "^1.9.6",
"reka-ui": "^2.8.2",
"tailwind-merge": "^2.6.0",
"focus-trap": "^8.2.0",
"parse-duration": "^2.1.6",
"pinia": "^3.0.4",
"radix-vue": "^1.9.17",
"reka-ui": "^2.9.7",
"tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7",
"vue-echarts": "^8.0.0",
"zod": "^3.23.8"
"vue-draggable-plus": "^0.6.1",
"vue-echarts": "^8.0.1",
"zod": "^3.25.76"
},
"overrides": {
"vite-plugin-checker": {

View File

@@ -32,6 +32,9 @@ export const test = baseTest.extend<
const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`;
const password = TEST_USER_PASSWORD;
const name = 'John Doe';
const timezone = await page.evaluate(
() => Intl.DateTimeFormat().resolvedOptions().timeZone
);
// Use page.context().request() so cookies are automatically shared with the page
const request = page.context().request;
@@ -64,6 +67,7 @@ export const test = baseTest.extend<
password,
password_confirmation: password,
terms: 'on',
timezone,
},
maxRedirects: 0,
});

View File

@@ -2,7 +2,7 @@
import { computed, nextTick, ref, watch } from 'vue';
import { useMembersQuery } from '@/utils/useMembersQuery';
import { UserIcon } from '@heroicons/vue/24/solid';
import { ChevronDown } from 'lucide-vue-next';
import { ChevronDown } from '@lucide/vue';
import type { ProjectMember } from '@/packages/api/src';
import type { Member } from '@/packages/api/src';
import {

View File

@@ -10,7 +10,7 @@ import {
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
import { Check, Plus } from 'lucide-vue-next';
import { Check, Plus } from '@lucide/vue';
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';

View File

@@ -12,7 +12,7 @@ import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
import { useClientsQuery } from '@/utils/useClientsQuery';
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
import { Button } from '@/packages/ui/src/Buttons';
import { ChevronDown } from 'lucide-vue-next';
import { ChevronDown } from '@lucide/vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';

View File

@@ -5,7 +5,7 @@ import {
EllipsisVerticalIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid';
import { SaveIcon } from 'lucide-vue-next';
import { SaveIcon } from '@lucide/vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import {
formatReportingDuration,

View File

@@ -11,7 +11,7 @@ import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';
import { Button } from '@/packages/ui/src/Buttons';
import { ChevronDown } from 'lucide-vue-next';
import { ChevronDown } from '@lucide/vue';
import { FolderIcon } from '@heroicons/vue/20/solid';
const { createTask } = useTasksStore();

View File

@@ -2,7 +2,7 @@
import { buttonVariants } from '@/packages/ui/src';
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';
import { cn } from '@/lib/utils';
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
@@ -13,7 +13,7 @@ const delegatedProps = computed(() => {
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="twMerge(buttonVariants(), props.class)">
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View File

@@ -2,7 +2,7 @@
import { buttonVariants } from '@/packages/ui/src';
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';
import { cn } from '@/lib/utils';
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>();
@@ -16,7 +16,7 @@ const delegatedProps = computed(() => {
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="twMerge(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
:class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
<slot />
</AlertDialogCancel>
</template>

View File

@@ -18,7 +18,7 @@ import {
XMarkIcon,
DocumentTextIcon,
} from '@heroicons/vue/20/solid';
import { PanelLeft } from 'lucide-vue-next';
import { PanelLeft } from '@lucide/vue';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';

View File

@@ -12,6 +12,7 @@
},
"scripts": {
"build": "vite build",
"watch": "vite build --watch",
"test": "echo \"Error: no test specified\" && exit 1"
},
"files": [
@@ -28,7 +29,7 @@
"author": "solidtime",
"license": "AGPL-3.0",
"devDependencies": {
"vite-plugin-dts": "^4.0.3"
"vite-plugin-dts": "^4.5.4"
},
"peerDependencies": {
"@zodios/core": "^10.9.6",

View File

@@ -122,6 +122,9 @@ export type CreateInvoiceBody = ZodiosBodyByAlias<SolidTimeApi, 'createInvoice'>
export type UpdateInvoiceBody = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoice'>;
export type User = ZodiosResponseByAlias<SolidTimeApi, 'getMe'>['data'];
export type UpdateUserBody = ZodiosBodyByAlias<SolidTimeApi, 'updateUser'>;
const api = createApiClient('/api', { validate: 'none' });
export { createApiClient, api };

View File

@@ -688,11 +688,22 @@ const UserResource = z
id: z.string(),
name: z.string(),
email: z.string(),
pending_email: z.union([z.string(), z.null()]),
profile_photo_url: z.string(),
timezone: z.string(),
week_start: Weekday,
})
.passthrough();
const UserUpdateRequest = z
.object({
name: z.string(),
email: z.string(),
photo: z.union([z.string(), z.null()]),
timezone: z.string(),
week_start: Weekday,
})
.partial()
.passthrough();
const PersonalMembershipResource = z
.object({
id: z.string(),
@@ -764,6 +775,7 @@ export const schemas = {
TimeEntryUpdateMultipleRequest,
TimeEntryUpdateRequest,
UserResource,
UserUpdateRequest,
PersonalMembershipResource,
};
@@ -4419,7 +4431,7 @@ The report is considered public if the &#x60;is_public&#x60; field is set to &#x
method: 'get',
path: '/v1/users/me',
alias: 'getMe',
description: `This endpoint is independent of organization.`,
description: `This endpoint is independent of the organization.`,
requestFormat: 'json',
response: z.object({ data: UserResource }).passthrough(),
errors: [
@@ -4435,6 +4447,79 @@ The report is considered public if the &#x60;is_public&#x60; field is set to &#x
},
],
},
{
method: 'put',
path: '/v1/users/:user',
alias: 'updateUser',
description: `This endpoint is independent of the organization.`,
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: UserUpdateRequest,
},
{
name: 'user',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: UserResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{
method: 'post',
path: '/v1/users/:user/resend-email-verification',
alias: 'resendUserEmailVerification',
description: `This endpoint is independent of the organization.`,
requestFormat: 'json',
parameters: [
{
name: 'user',
type: 'Path',
schema: z.string(),
},
],
response: z.void(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'get',
path: '/v1/users/me/api-tokens',

View File

@@ -48,10 +48,10 @@
"author": "solidtime",
"license": "AGPL-3.0",
"devDependencies": {
"@types/chroma-js": "^3.1.0",
"@types/chroma-js": "^3.1.2",
"@zodios/core": "^10.9.6",
"vite-plugin-dts": "^4.0.3",
"zod": "^3.23.8"
"vite-plugin-dts": "^4.5.4",
"zod": "^3.25.76"
},
"peerDependencies": {
"@floating-ui/vue": "^1.1.4",
@@ -64,7 +64,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"lucide-vue-next": ">=0.453.0",
"@lucide/vue": ">=1.0.0",
"@internationalized/date": "^3.0.0",
"parse-duration": "^2.0.1",
"radix-vue": "^1.9.0",

View File

@@ -11,7 +11,7 @@ import {
} from 'radix-vue';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { Check, Plus } from 'lucide-vue-next';
import { Check, Plus } from '@lucide/vue';
const model = defineModel<string | null>({
default: null,

View File

@@ -2,7 +2,7 @@
import { Popover, PopoverContent, PopoverTrigger, Button } from '..';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Field, FieldLabel } from '../field';
import { Settings } from 'lucide-vue-next';
import { Settings } from '@lucide/vue';
import { ref, watch } from 'vue';
import type { CalendarSettings } from './calendarSettings';

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Button } from '..';
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { Tabs, TabsList } from '../tabs';
import TabBarItem from '../TabBar/TabBarItem.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';

View File

@@ -9,7 +9,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src/popover';
import { Calendar } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import { CalendarIcon, XIcon } from 'lucide-vue-next';
import { CalendarIcon, XIcon } from '@lucide/vue';
import { parseDate, type DateValue } from '@internationalized/date';
import type { Organization } from '@/packages/api/src';

View File

@@ -3,7 +3,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import Button from '../Buttons/Button.vue';
import { RangeCalendar } from '../range-calendar';
import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next';
import { CalendarIcon } from '@lucide/vue';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import {

View File

@@ -10,7 +10,7 @@ import { useFocus } from '@vueuse/core';
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
import { Button } from '@/packages/ui/src/Buttons';
import { ChevronDown } from 'lucide-vue-next';
import { ChevronDown } from '@lucide/vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
import { Field, FieldGroup, FieldLabel } from '../field';

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { ChevronDown } from 'lucide-vue-next';
import { ChevronDown } from '@lucide/vue';
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { CalendarCell, type CalendarCellProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';
import { cn } from '../utils/cn';
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>();
@@ -17,7 +17,7 @@ const forwardedProps = useForwardProps(delegatedProps);
<template>
<CalendarCell
:class="
twMerge(
cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50',
props.class
)

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src/index';
import { ChevronRight } from 'lucide-vue-next';
import { ChevronRight } from '@lucide/vue';
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { ChevronLeft } from 'lucide-vue-next';
import { ChevronLeft } from '@lucide/vue';
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -2,7 +2,7 @@
import type { ListboxFilterProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { Search } from 'lucide-vue-next';
import { Search } from '@lucide/vue';
import { ListboxFilter, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
import { useCommand } from '.';

View File

@@ -2,7 +2,7 @@
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { Check } from 'lucide-vue-next';
import { Check } from '@lucide/vue';
import { ContextMenuCheckboxItem, ContextMenuItemIndicator, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';

View File

@@ -2,7 +2,7 @@
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { Circle } from 'lucide-vue-next';
import { Circle } from '@lucide/vue';
import { ContextMenuItemIndicator, ContextMenuRadioItem, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';

View File

@@ -2,7 +2,7 @@
import type { ContextMenuSubTriggerProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ChevronRight } from 'lucide-vue-next';
import { ChevronRight } from '@lucide/vue';
import { ContextMenuSubTrigger, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { X } from 'lucide-vue-next';
import { X } from '@lucide/vue';
import {
DialogClose,
DialogContent,

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { Check } from 'lucide-vue-next';
import { Check } from '@lucide/vue';
import {
DropdownMenuCheckboxItem,
type DropdownMenuCheckboxItemEmits,

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { Circle } from 'lucide-vue-next';
import { Circle } from '@lucide/vue';
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { ChevronRight } from 'lucide-vue-next';
import { ChevronRight } from '@lucide/vue';
import { DropdownMenuSubTrigger, type DropdownMenuSubTriggerProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { NumberFieldDecrementProps } from 'reka-ui';
import { cn } from '../utils/cn';
import { Minus } from 'lucide-vue-next';
import { Minus } from '@lucide/vue';
import { NumberFieldDecrement, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { NumberFieldIncrementProps } from 'reka-ui';
import { cn } from '../utils/cn';
import { Plus } from 'lucide-vue-next';
import { Plus } from '@lucide/vue';
import { NumberFieldIncrement, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { ChevronRight } from 'lucide-vue-next';
import { ChevronRight } from '@lucide/vue';
import { RangeCalendarNext, type RangeCalendarNextProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { ChevronLeft } from 'lucide-vue-next';
import { ChevronLeft } from '@lucide/vue';
import { RangeCalendarPrev, type RangeCalendarPrevProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { Check } from 'lucide-vue-next';
import { Check } from '@lucide/vue';
import {
SelectItem,
SelectItemIndicator,

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { ChevronDown } from 'lucide-vue-next';
import { ChevronDown } from '@lucide/vue';
import { SelectScrollDownButton, type SelectScrollDownButtonProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { ChevronUp } from 'lucide-vue-next';
import { ChevronUp } from '@lucide/vue';
import { SelectScrollUpButton, type SelectScrollUpButtonProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { ChevronDown } from 'lucide-vue-next';
import { ChevronDown } from '@lucide/vue';
import { SelectIcon, SelectTrigger, type SelectTriggerProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -19,7 +19,7 @@ export default defineConfig({
// into your library
external: [
'vue',
'lucide-vue-next',
'@lucide/vue',
'@floating-ui/vue',
'@heroicons/vue',
/^@heroicons\/vue\/.*/,

View File

@@ -63,6 +63,7 @@ export interface User {
id: string;
name: string;
email: string;
pending_email: string | null;
email_verified_at: string | null;
password?: string;
remember_token?: string | null;

View File

@@ -80,6 +80,7 @@ export interface User {
id: string;
name: string;
email: string;
pending_email: string | null;
email_verified_at: string | null;
password?: string;
remember_token?: string | null;

View File

@@ -108,10 +108,9 @@ class ProfileInformationTest extends TestCase
$response = $this->get($verificationUrl);
// Assert
$response->assertRedirect(route('dashboard', [
'bannerStyle' => 'success',
'bannerText' => 'Your email address has been updated successfully.',
]));
$response->assertRedirect(route('dashboard'));
$response->assertSessionHas('bannerStyle', 'success');
$response->assertSessionHas('bannerText', 'Your email address has been updated successfully.');
$user = $user->fresh();
$this->assertEquals('new.email@example.com', $user->email);
$this->assertNull($user->pending_email);
@@ -144,6 +143,40 @@ class ProfileInformationTest extends TestCase
$this->assertEquals('new.email@example.com', $user->pending_email);
}
public function test_pending_email_verification_redirects_with_danger_banner_when_email_already_in_use(): void
{
// Arrange
User::factory()->create([
'email' => 'taken@example.com',
'is_placeholder' => false,
]);
$user = User::factory()->create([
'email' => 'current@example.com',
'pending_email' => 'taken@example.com',
]);
$this->actingAs($user);
$verificationUrl = URL::temporarySignedRoute(
'users.verify-email-change',
now()->addMinutes(60),
[
'user' => $user->getKey(),
'email' => 'taken@example.com',
],
false
);
// Act
$response = $this->get($verificationUrl);
// Assert
$response->assertRedirect(route('dashboard'));
$response->assertSessionHas('bannerStyle', 'danger');
$response->assertSessionHas('bannerText', 'The email address is already in use.');
$user = $user->fresh();
$this->assertEquals('current@example.com', $user->email);
$this->assertEquals('taken@example.com', $user->pending_email);
}
public function test_stale_pending_email_verification_link_is_rejected(): void
{
// Arrange

View File

@@ -133,6 +133,59 @@ class UserEndpointTest extends ApiEndpointTestAbstract
});
}
public function test_update_with_the_current_email_does_not_change_pending_email_or_send_a_mail(): void
{
// Arrange
Mail::fake();
$data = $this->createUserWithPermission();
$data->user->email = 'current@example.com';
$data->user->pending_email = null;
$data->user->save();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'email' => 'current@example.com',
]);
// Assert
$response->assertSuccessful();
$user = $data->user->fresh();
$this->assertSame('current@example.com', $user->email);
$this->assertNull($user->pending_email);
Mail::assertNothingSent();
}
public function test_update_fails_if_email_format_is_invalid(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'email' => 'not-an-email',
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['email']);
}
public function test_update_fails_when_not_authenticated(): void
{
// Arrange
$data = $this->createUserWithPermission();
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'name' => 'Anonymous Edit',
]);
// Assert
$response->assertUnauthorized();
}
public function test_resend_email_verification_sends_pending_email_verification_email(): void
{
// Arrange
@@ -354,6 +407,94 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonValidationErrors(['photo']);
}
public function test_update_fails_if_photo_exceeds_1_megabyte(): void
{
// Arrange
$data = $this->createUserWithPermission();
$photo = file_get_contents(resource_path('testfiles/test.png'));
$this->assertIsString($photo);
$photo .= str_repeat("\0", 1024 * 1024);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'photo' => base64_encode($photo),
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['photo']);
}
public function test_update_with_null_photo_deletes_photo_file_and_clears_profile_photo_path(): void
{
// Arrange
$data = $this->createUserWithPermission();
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
$photoPath = 'profile-photos/existing.png';
Storage::fake($photoDisk);
Storage::disk($photoDisk)->put($photoPath, 'photo contents');
$data->user->profile_photo_path = $photoPath;
$data->user->save();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'photo' => null,
]);
// Assert
$response->assertSuccessful();
$user = $data->user->fresh();
$this->assertNull($user->profile_photo_path);
Storage::disk($photoDisk)->assertMissing($photoPath);
}
public function test_update_with_null_photo_is_a_noop_when_user_has_no_photo(): void
{
// Arrange
$data = $this->createUserWithPermission();
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
Storage::fake($photoDisk);
$data->user->profile_photo_path = null;
$data->user->save();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'photo' => null,
]);
// Assert
$response->assertSuccessful();
$user = $data->user->fresh();
$this->assertNull($user->profile_photo_path);
}
public function test_update_without_photo_key_leaves_existing_photo_untouched(): void
{
// Arrange
$data = $this->createUserWithPermission();
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
$photoPath = 'profile-photos/existing.png';
Storage::fake($photoDisk);
Storage::disk($photoDisk)->put($photoPath, 'photo contents');
$data->user->profile_photo_path = $photoPath;
$data->user->save();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'name' => 'Just A Name Change',
]);
// Assert
$response->assertSuccessful();
$user = $data->user->fresh();
$this->assertSame($photoPath, $user->profile_photo_path);
Storage::disk($photoDisk)->assertExists($photoPath);
}
public function test_delete_fails_if_given_user_is_not_the_authenticated_user(): void
{
// Arrange