Files
zooqueen ad3c58d9a0 fix: confirm a saved profile, decide the upload store once, agree on ids
Three unrelated defects, one image.

The profile toast fired only after a session refresh. That refresh is a second
read: it can stall, and when it answers unauthenticated it clears the session,
which throws in useSession and takes down the subtree holding the toaster. A
save that had already persisted could therefore go unconfirmed. The save is
confirmed on the save now and the refresh follows it, so the confirmation
depends only on the thing it reports.

Document bytes are routed by NEXT_PUBLIC_UPLOAD_TRANSPORT. Three places read it
with three slightly different rules, and the two that chose a store fell through
to the database for any value they did not recognise -- so a transport named
under a variable nothing reads stored every PDF inline while the configuration
read as object storage, with nothing logged either way. One function resolves it
now: unset means the database, and an unrecognised value throws rather than
quietly picking a store. Reads still dispatch on each row's own type, so rows
written under either transport keep working.

entry.client and entry.server built different root trees -- StrictMode on the
client only, and a second child beside the router. useId numbers a component by
its position, and a parent's child count is part of that position, so every id
below the root diverged and hydration failed on the first component to ask for
one. Measured on the documents page: the header's menu trigger came back
radix-:R26j5: from the server and radix-:R8qcl: from the client. The trees are
identical now and all seven server-rendered ids survive hydration unchanged.

Also: .gitignore still named the license artifact by its pre-rebrand filename,
so the file the app actually writes was landing in git status untracked.
2026-08-14 09:24:44 -07:00

157 lines
4.8 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useSession } from '@hanzo/esign-lib/client-only/providers/session';
import type { TUpdateProfileMutationSchema } from '@hanzo/esign-trpc/server/profile-router/schema';
import { useZapMutation } from '@hanzo/esign-trpc/zap/react';
import { cn } from '@hanzo/esign-ui/lib/utils';
import { Button } from '@hanzo/esign-ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@hanzo/esign-ui/primitives/form/form';
import { Input } from '@hanzo/esign-ui/primitives/input';
import { Label } from '@hanzo/esign-ui/primitives/label';
import { SignaturePadDialog } from '@hanzo/esign-ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
export const ZProfileFormSchema = z.object({
name: z
.string()
.trim()
.min(1, { message: msg`Please enter a valid name.`.id }),
// A signature is drawn when signing, not when the profile is saved, so the
// name can be updated before one exists. Matches the server schema.
signature: z.string(),
});
export const ZTwoFactorAuthTokenSchema = z.object({
token: z.string(),
});
export type TTwoFactorAuthTokenSchema = z.infer<typeof ZTwoFactorAuthTokenSchema>;
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
export type ProfileFormProps = {
className?: string;
};
export const ProfileForm = ({ className }: ProfileFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user, refreshSession } = useSession();
const form = useForm<TProfileFormSchema>({
values: {
name: user.name ?? '',
signature: user.signature || '',
},
resolver: zodResolver(ZProfileFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: updateProfile } = useZapMutation<void, TUpdateProfileMutationSchema>(
'profile.updateProfile',
);
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
try {
await updateProfile({
name,
signature,
});
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting update your profile. Please try again later.`,
),
variant: 'destructive',
});
return;
}
// The save is what this reports, so it fires on the save. Refreshing the
// session is a second read that can be slow, fail, or come back
// unauthenticated — and an unauthenticated read clears the session, which
// takes down the subtree holding the toaster. Confirming first means the
// save is always confirmed.
toast({
title: _(msg`Profile updated`),
description: _(msg`Your profile has been updated successfully.`),
duration: 5000,
});
await refreshSession();
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Full Name</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Label htmlFor="email" className="text-muted-foreground">
<Trans>Email</Trans>
</Label>
<Input id="email" type="email" className="mt-2 bg-muted" value={user.email} disabled />
</div>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange, value } }) => (
<FormItem>
<FormLabel>
<Trans>Signature</Trans>
</FormLabel>
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
fullName={user.name ?? ''}
value={value}
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isSubmitting} className="self-end">
<Trans>Update profile</Trans>
</Button>
</form>
</Form>
);
};