Phase 6
Owner Administration is complete. Summary of this final stretch of work: Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB. Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres: Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation). Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres. 12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively. Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected. Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
73 files changed
+8400
-53
No files matched your search
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { acceptInvitation, validateInvitationToken } from "@/services/staff/staff-invitation.service";
|
||||
import { acceptInvitationSchema } from "@/features/auth/schemas";
|
||||
|
||||
export interface AcceptInvitationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
/** Public — the invited person has no account/session yet. Mirrors registerAction's shape: create the credential, then sign in immediately, same as a fresh registration. */
|
||||
export async function acceptInvitationAction(token: string, formData: FormData): Promise<AcceptInvitationResult> {
|
||||
const parsed = acceptInvitationSchema.safeParse({
|
||||
password: formData.get("password"),
|
||||
confirmPassword: formData.get("confirmPassword"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const check = await validateInvitationToken(prisma, token);
|
||||
if (!check.valid) {
|
||||
const message =
|
||||
check.reason === "EXPIRED"
|
||||
? "This invitation link has expired. Ask the business owner to send you a new one."
|
||||
: check.reason === "ALREADY_ACCEPTED"
|
||||
? "This invitation has already been used — try signing in instead."
|
||||
: "This invitation link isn't valid.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
|
||||
const result = await acceptInvitation(prisma, { token, password: parsed.data.password });
|
||||
if (!result.success) {
|
||||
return { success: false, error: "Couldn't accept this invitation. Please try again." };
|
||||
}
|
||||
|
||||
await signIn("credentials", {
|
||||
email: check.invitation.staff.user.email,
|
||||
password: parsed.data.password,
|
||||
redirectTo: "/staff",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { acceptInvitationAction } from "@/features/auth/actions/accept-invitation";
|
||||
import { acceptInvitationSchema } from "@/features/auth/schemas";
|
||||
import type { AcceptInvitationInput } from "@/features/auth/schemas";
|
||||
|
||||
export function AcceptInvitationForm({ token, businessName }: { token: string; businessName: string }) {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AcceptInvitationInput>({ resolver: zodResolver(acceptInvitationSchema) });
|
||||
|
||||
const onSubmit = async (data: AcceptInvitationInput) => {
|
||||
setFormError(null);
|
||||
const formData = new FormData();
|
||||
formData.set("password", data.password);
|
||||
formData.set("confirmPassword", data.confirmPassword);
|
||||
|
||||
const result = await acceptInvitationAction(token, formData);
|
||||
if (!result.success) {
|
||||
setFormError(result.error ?? "Something went wrong. Please try again.");
|
||||
if (result.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(result.fieldErrors)) {
|
||||
if (messages?.[0]) setError(field as keyof AcceptInvitationInput, { message: messages[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
You've been invited to join <span className="text-ink font-bold">{businessName}</span> on NextChair. Set
|
||||
a password to finish setting up your account.
|
||||
</p>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
{formError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
|
||||
Set Password & Sign In
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -26,3 +26,14 @@ export const registerSchema = z
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
|
||||
export const acceptInvitationSchema = z
|
||||
.object({
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;
|
||||
Reference in new issue
Block a user