mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 05:02:14 +01:00
Compare commits
3 Commits
22e865a69e
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb4bb6ef33 | ||
|
|
dc5e8e7de2 | ||
|
|
5821f7c688 |
@@ -81,6 +81,7 @@ test('profile photo can be uploaded, persists across reload, and can be removed'
|
|||||||
reloadedForm.getByRole('button', { name: 'Remove Photo' }).click(),
|
reloadedForm.getByRole('button', { name: 'Remove Photo' }).click(),
|
||||||
]);
|
]);
|
||||||
await expect(reloadedProfilePhoto).toHaveAttribute('src', /ui-avatars\.com/);
|
await expect(reloadedProfilePhoto).toHaveAttribute('src', /ui-avatars\.com/);
|
||||||
|
await expect(reloadedForm.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
const finalForm = profileInformationForm(page);
|
const finalForm = profileInformationForm(page);
|
||||||
@@ -88,6 +89,7 @@ test('profile photo can be uploaded, persists across reload, and can be removed'
|
|||||||
'src',
|
'src',
|
||||||
/ui-avatars\.com/
|
/ui-avatars\.com/
|
||||||
);
|
);
|
||||||
|
await expect(finalForm.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('field-level validation errors render inline when the server returns 422', async ({
|
test('field-level validation errors render inline when the server returns 422', async ({
|
||||||
@@ -206,6 +208,41 @@ test('clicking resend sends a second verification email and shows confirmation',
|
|||||||
expect(afterCount).toBeGreaterThan(beforeCount);
|
expect(afterCount).toBeGreaterThan(beforeCount);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cancelling a pending email change clears it and hides the banner', async ({ page, ctx }) => {
|
||||||
|
const { email: currentEmail } = await getCurrentUserViaApi(ctx);
|
||||||
|
const newEmail = `cancel+${Date.now()}@test.com`;
|
||||||
|
|
||||||
|
await goToProfilePage(page);
|
||||||
|
await page.getByLabel('Email').fill(newEmail);
|
||||||
|
await saveProfileForm(page);
|
||||||
|
|
||||||
|
// The pending-email banner is shown with the cancel control.
|
||||||
|
await expect(page.getByText('A verification link was sent to')).toBeVisible();
|
||||||
|
await expect(page.getByText(newEmail)).toBeVisible();
|
||||||
|
const cancelButton = page.getByRole('button', { name: 'Cancel email change' });
|
||||||
|
await expect(cancelButton).toBeVisible();
|
||||||
|
|
||||||
|
// Cancelling clears the pending email server-side (204).
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/reset-pending-email') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
cancelButton.click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The banner disappears and the email field still shows the current address.
|
||||||
|
await expect(page.getByText('A verification link was sent to')).toBeHidden();
|
||||||
|
await expect(page.getByLabel('Email')).toHaveValue(currentEmail);
|
||||||
|
|
||||||
|
// The cancellation is persistent — still gone after a reload.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText('A verification link was sent to')).toBeHidden();
|
||||||
|
await expect(page.getByLabel('Email')).toHaveValue(currentEmail);
|
||||||
|
});
|
||||||
|
|
||||||
test('re-submitting the same pending email does not send another verification email', async ({
|
test('re-submitting the same pending email does not send another verification email', async ({
|
||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { usePage } from '@inertiajs/vue3';
|
|||||||
const ALLOWED_STYLES = ['success', 'danger', 'info', 'warning'] as const;
|
const ALLOWED_STYLES = ['success', 'danger', 'info', 'warning'] as const;
|
||||||
type BannerStyle = (typeof ALLOWED_STYLES)[number];
|
type BannerStyle = (typeof ALLOWED_STYLES)[number];
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
// Render as a self-contained rounded alert that sits inside a card
|
||||||
|
// (e.g. the auth card on login/register) instead of a full-width page banner.
|
||||||
|
card?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ card: false }
|
||||||
|
);
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
flash: {
|
flash: {
|
||||||
bannerText?: string;
|
bannerText?: string;
|
||||||
@@ -26,10 +35,16 @@ const show = ref(true);
|
|||||||
<div
|
<div
|
||||||
v-if="show && message"
|
v-if="show && message"
|
||||||
data-testid="banner"
|
data-testid="banner"
|
||||||
class="bg-secondary border-b border-border-secondary">
|
:class="
|
||||||
<div class="mx-auto py-1 px-3 sm:px-6 lg:px-8">
|
card
|
||||||
|
? 'bg-secondary border border-border-secondary rounded-lg mb-4'
|
||||||
|
: 'bg-secondary border-b border-border-secondary'
|
||||||
|
">
|
||||||
|
<div :class="card ? 'py-2 px-3' : 'mx-auto py-1 px-3 sm:px-6 lg:px-8'">
|
||||||
<div class="flex items-center justify-between flex-wrap">
|
<div class="flex items-center justify-between flex-wrap">
|
||||||
<div class="w-0 flex-1 flex items-center min-w-0">
|
<div
|
||||||
|
class="w-0 flex-1 flex min-w-0"
|
||||||
|
:class="card ? 'items-start' : 'items-center'">
|
||||||
<span class="flex">
|
<span class="flex">
|
||||||
<svg
|
<svg
|
||||||
v-if="style === 'success'"
|
v-if="style === 'success'"
|
||||||
@@ -74,7 +89,9 @@ const show = ref(true);
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<p class="ms-3 font-medium text-sm text-text-primary truncate">
|
<p
|
||||||
|
class="ms-3 font-medium text-sm text-text-primary"
|
||||||
|
:class="{ truncate: !card }">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ const page = usePage<{
|
|||||||
<template>
|
<template>
|
||||||
<Head title="Log in" />
|
<Head title="Log in" />
|
||||||
|
|
||||||
<Banner />
|
|
||||||
|
|
||||||
<AuthenticationCard>
|
<AuthenticationCard>
|
||||||
<template #logo>
|
<template #logo>
|
||||||
<AuthenticationCardLogo />
|
<AuthenticationCardLogo />
|
||||||
@@ -52,6 +50,8 @@ const page = usePage<{
|
|||||||
</Link>
|
</Link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<Banner card />
|
||||||
|
|
||||||
<div v-if="status" class="mb-4 font-medium text-sm text-green-400">
|
<div v-if="status" class="mb-4 font-medium text-sm text-green-400">
|
||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ const page = usePage<{
|
|||||||
<template>
|
<template>
|
||||||
<Head title="Register" />
|
<Head title="Register" />
|
||||||
|
|
||||||
<Banner />
|
|
||||||
|
|
||||||
<AuthenticationCard>
|
<AuthenticationCard>
|
||||||
<template #logo>
|
<template #logo>
|
||||||
<AuthenticationCardLogo />
|
<AuthenticationCardLogo />
|
||||||
@@ -58,6 +56,8 @@ const page = usePage<{
|
|||||||
</Link>
|
</Link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<Banner card />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="page.props.flash?.message"
|
v-if="page.props.flash?.message"
|
||||||
class="bg-red-400 text-black text-center w-full px-3 py-1 mb-4 rounded-lg">
|
class="bg-red-400 text-black text-center w-full px-3 py-1 mb-4 rounded-lg">
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import axios from 'axios';
|
|||||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||||
import FormSection from '@/Components/FormSection.vue';
|
import FormSection from '@/Components/FormSection.vue';
|
||||||
import { Field, FieldError, FieldLabel } from '@/packages/ui/src/field';
|
import { Field, FieldError, FieldLabel } from '@/packages/ui/src/field';
|
||||||
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||||
import {
|
import {
|
||||||
useResendUserEmailVerificationMutation,
|
useResendUserEmailVerificationMutation,
|
||||||
|
useResetUserPendingEmailMutation,
|
||||||
useUpdateUserMutation,
|
useUpdateUserMutation,
|
||||||
useUserQuery,
|
useUserQuery,
|
||||||
} from '@/utils/useUserQuery';
|
} from '@/utils/useUserQuery';
|
||||||
@@ -18,6 +20,7 @@ import type { UpdateUserBody, User } from '@/packages/api/src';
|
|||||||
const { user } = useUserQuery();
|
const { user } = useUserQuery();
|
||||||
const updateUser = useUpdateUserMutation();
|
const updateUser = useUpdateUserMutation();
|
||||||
const resendVerification = useResendUserEmailVerificationMutation();
|
const resendVerification = useResendUserEmailVerificationMutation();
|
||||||
|
const resetPendingEmail = useResetUserPendingEmailMutation();
|
||||||
|
|
||||||
const name = ref('');
|
const name = ref('');
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
@@ -152,6 +155,17 @@ async function clickResend() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clickCancelEmailChange() {
|
||||||
|
if (!user.value || resetPendingEmail.isPending.value) return;
|
||||||
|
try {
|
||||||
|
// Clears pending_email on the server; the pending banner hides once the
|
||||||
|
// me query refetches. The email field already shows the current address.
|
||||||
|
await resetPendingEmail.mutateAsync(user.value.id);
|
||||||
|
} catch {
|
||||||
|
// notification handled by mutation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function flashSaved() {
|
function flashSaved() {
|
||||||
recentlySaved.value = true;
|
recentlySaved.value = true;
|
||||||
setTimeout(() => (recentlySaved.value = false), 2000);
|
setTimeout(() => (recentlySaved.value = false), 2000);
|
||||||
@@ -259,15 +273,26 @@ const page = usePage<{
|
|||||||
<span class="font-medium">{{ pendingEmail }}</span
|
<span class="font-medium">{{ pendingEmail }}</span
|
||||||
>. Click the link in the email to confirm the change.
|
>. Click the link in the email to confirm the change.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div class="mt-2 -ms-3 flex flex-wrap items-center gap-x-1 gap-y-1">
|
||||||
|
<Button
|
||||||
v-if="!resendCooldown"
|
v-if="!resendCooldown"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-1 underline text-text-secondary hover:text-text-primary rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
:disabled="!isUserLoaded || resendVerification.isPending.value"
|
:disabled="!isUserLoaded || resendVerification.isPending.value"
|
||||||
@click="clickResend">
|
@click="clickResend">
|
||||||
Resend verification email
|
Resend verification email
|
||||||
</button>
|
</Button>
|
||||||
<p v-else class="mt-1 font-medium text-green-400">Verification email sent.</p>
|
<p v-else class="ms-3 font-medium text-green-400">Verification email sent.</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isUserLoaded || resetPendingEmail.isPending.value"
|
||||||
|
@click="clickCancelEmailChange">
|
||||||
|
Cancel email change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|||||||
@@ -1898,6 +1898,54 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/v1/organizations/:organization/invoices/:invoice/copy',
|
||||||
|
alias: 'copyInvoice',
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
type: 'Body',
|
||||||
|
schema: z.object({ reference: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'invoice',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.object({ data: DetailedInvoiceResource }).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: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
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: 'get',
|
method: 'get',
|
||||||
path: '/v1/organizations/:organization/invoices/:invoice',
|
path: '/v1/organizations/:organization/invoices/:invoice',
|
||||||
@@ -4525,6 +4573,38 @@ The report is considered public if the `is_public` field is set to &#x
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/v1/users/:user/reset-pending-email',
|
||||||
|
alias: 'resetUserPendingEmail',
|
||||||
|
description: `This endpoint is independent of the organization.`,
|
||||||
|
requestFormat: 'json',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'user',
|
||||||
|
type: 'Path',
|
||||||
|
schema: z.string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: z.void(),
|
||||||
|
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: 404,
|
||||||
|
description: `Not found`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'post',
|
method: 'post',
|
||||||
path: '/v1/users/:user/resend-email-verification',
|
path: '/v1/users/:user/resend-email-verification',
|
||||||
|
|||||||
@@ -93,3 +93,21 @@ export function useResendUserEmailVerificationMutation() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useResetUserPendingEmailMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userId: string) => {
|
||||||
|
return handleApiRequestNotifications(
|
||||||
|
() => api.resetUserPendingEmail(undefined, { params: { user: userId } }),
|
||||||
|
'Email change canceled',
|
||||||
|
'Failed to cancel email change'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user