Files
pn-new-crm/src/app/(auth)/setup/page.tsx

203 lines
6.7 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
feat(branding): multi-tenant brand naming + per-port email shell + auth UI continuity Removes the last hardcoded "Port Nimara" references so a tenant cloning the deploy with a fresh slug sees their own brand throughout. Browser + native chrome: - `generateMetadata` reads `branding_app_name` from the first port row so the browser tab title, apple-web-app title, and template literal reflect the tenant (fallback "CRM" until DB is seeded). - Mobile topbar derives the brand-mark initials from the port slug ("port-nimara" → "PN", "marina-alpha" → "MA") — no code edit on clone. - `documenso-payload` default redirect URL is `""` so Documenso falls back to its own post-sign page instead of routing every tenant's signers to portnimara.com; per-port `redirectUrl` setting still wins. - Server-startup log uses generic "CRM server listening". Email + auth shell: - New `auth-shell-branding.ts` resolves logo / background / appName once per request from `system_settings`; used by both the email shell and the auth-pages SSR layout. - `auth-branding-provider` wraps `/login`, `/reset-password`, `/set-password`, portal `/portal/*` so the branded shell hydrates with the same assets the inbox sees. - `me/email` change email uses the branded shell instead of inline HTML with "Port Nimara CRM" baked into copy. - Admin branding page adds an email-preview card (POSTs to `/api/v1/admin/branding/email-preview`) so an admin can spot-check their templates before going live. - `/api/public/files/[id]` exposes branding-category files anonymously so inbox images (no session cookie) can render; any other category still flows through authenticated `/api/v1/files/[id]/preview`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:54:10 +02:00
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
import { FormErrorSummary } from '@/components/forms/form-error-summary';
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
const setupSchema = z.object({
name: z.string().min(1, 'Name is required').max(120),
email: z.string().email('Valid email is required').max(254),
password: z.string().min(9, 'Password must be at least 9 characters').max(200),
confirmPassword: z.string(),
});
type SetupFormData = z.infer<typeof setupSchema>;
interface StatusResp {
data: { needsBootstrap: boolean };
}
/**
* First-run setup. On a fresh DB the very first visitor can claim the
* super-admin account here. Once anyone claims it, future visits to
* /setup redirect back to /login - the precondition is verified both
* server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s
* internal recheck) and client-side here.
*/
export default function SetupPage() {
const router = useRouter();
feat(branding): multi-tenant brand naming + per-port email shell + auth UI continuity Removes the last hardcoded "Port Nimara" references so a tenant cloning the deploy with a fresh slug sees their own brand throughout. Browser + native chrome: - `generateMetadata` reads `branding_app_name` from the first port row so the browser tab title, apple-web-app title, and template literal reflect the tenant (fallback "CRM" until DB is seeded). - Mobile topbar derives the brand-mark initials from the port slug ("port-nimara" → "PN", "marina-alpha" → "MA") — no code edit on clone. - `documenso-payload` default redirect URL is `""` so Documenso falls back to its own post-sign page instead of routing every tenant's signers to portnimara.com; per-port `redirectUrl` setting still wins. - Server-startup log uses generic "CRM server listening". Email + auth shell: - New `auth-shell-branding.ts` resolves logo / background / appName once per request from `system_settings`; used by both the email shell and the auth-pages SSR layout. - `auth-branding-provider` wraps `/login`, `/reset-password`, `/set-password`, portal `/portal/*` so the branded shell hydrates with the same assets the inbox sees. - `me/email` change email uses the branded shell instead of inline HTML with "Port Nimara CRM" baked into copy. - Admin branding page adds an email-preview card (POSTs to `/api/v1/admin/branding/email-preview`) so an admin can spot-check their templates before going live. - `/api/public/files/[id]` exposes branding-category files anonymously so inbox images (no session cookie) can render; any other category still flows through authenticated `/api/v1/files/[id]/preview`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:54:10 +02:00
const branding = useAuthBranding();
const appName = branding?.appName?.trim() || 'this CRM';
const [checking, setChecking] = useState(true);
const [submitting, setSubmitting] = useState(false);
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<SetupFormData>({
resolver: zodResolver(setupSchema),
});
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
useEffect(() => {
let cancelled = false;
async function check() {
try {
const res = await apiFetch<StatusResp>('/api/v1/bootstrap/status');
if (cancelled) return;
if (!res.data.needsBootstrap) {
// Already initialized - bounce to login. Replace, not push,
// so back-button doesn't trap the user here.
router.replace('/login');
return;
}
} catch {
// Status endpoint failed - let the user try anyway; the POST
// does its own check and will surface a 409 if the window closed.
} finally {
if (!cancelled) setChecking(false);
}
}
void check();
return () => {
cancelled = true;
};
}, [router]);
async function onSubmit(data: SetupFormData) {
if (data.password !== data.confirmPassword) {
toast.error('Passwords do not match');
return;
}
setSubmitting(true);
try {
await apiFetch('/api/v1/bootstrap/super-admin', {
method: 'POST',
body: {
name: data.name,
email: data.email,
password: data.password,
},
});
toast.success('Administrator account created - sign in to continue.');
router.replace('/login');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create administrator account');
} finally {
setSubmitting(false);
}
}
if (checking) {
return (
<BrandedAuthShell>
<div className="text-center text-sm text-muted-foreground">Checking setup state</div>
</BrandedAuthShell>
);
}
return (
<BrandedAuthShell>
<div className="space-y-6">
<div className="text-center space-y-1">
feat(branding): multi-tenant brand naming + per-port email shell + auth UI continuity Removes the last hardcoded "Port Nimara" references so a tenant cloning the deploy with a fresh slug sees their own brand throughout. Browser + native chrome: - `generateMetadata` reads `branding_app_name` from the first port row so the browser tab title, apple-web-app title, and template literal reflect the tenant (fallback "CRM" until DB is seeded). - Mobile topbar derives the brand-mark initials from the port slug ("port-nimara" → "PN", "marina-alpha" → "MA") — no code edit on clone. - `documenso-payload` default redirect URL is `""` so Documenso falls back to its own post-sign page instead of routing every tenant's signers to portnimara.com; per-port `redirectUrl` setting still wins. - Server-startup log uses generic "CRM server listening". Email + auth shell: - New `auth-shell-branding.ts` resolves logo / background / appName once per request from `system_settings`; used by both the email shell and the auth-pages SSR layout. - `auth-branding-provider` wraps `/login`, `/reset-password`, `/set-password`, portal `/portal/*` so the branded shell hydrates with the same assets the inbox sees. - `me/email` change email uses the branded shell instead of inline HTML with "Port Nimara CRM" baked into copy. - Admin branding page adds an email-preview card (POSTs to `/api/v1/admin/branding/email-preview`) so an admin can spot-check their templates before going live. - `/api/public/files/[id]` exposes branding-category files anonymously so inbox images (no session cookie) can render; any other category still flows through authenticated `/api/v1/files/[id]/preview`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:54:10 +02:00
<h1 className="text-xl font-semibold">Welcome to {appName}</h1>
<p className="text-sm text-muted-foreground">
No administrator account exists yet. Create one to get started - you&rsquo;ll be the
super-administrator for this installation.
</p>
</div>
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4">
<FormErrorSummary
errors={errors}
labels={{
name: 'Name',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm password',
}}
/>
<div className="space-y-1.5">
<Label htmlFor="setup-name">Your name</Label>
<Input
id="setup-name"
placeholder="Jane Operator"
autoComplete="name"
{...register('name')}
className={cn(errors.name && 'border-destructive')}
/>
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="setup-email">Email</Label>
<Input
id="setup-email"
type="email"
placeholder="you@example.com"
autoComplete="email"
{...register('email')}
className={cn(errors.email && 'border-destructive')}
/>
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="setup-password">Password</Label>
<Input
id="setup-password"
type="password"
placeholder="At least 9 characters"
autoComplete="new-password"
{...register('password')}
className={cn(errors.password && 'border-destructive')}
/>
{errors.password && (
<p className="text-xs text-destructive">{errors.password.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="setup-confirm">Confirm password</Label>
<Input
id="setup-confirm"
type="password"
autoComplete="new-password"
{...register('confirmPassword')}
className={cn(
watch('password') !== watch('confirmPassword') &&
watch('confirmPassword')?.length > 0 &&
'border-destructive',
)}
/>
</div>
<Button type="submit" className="w-full" disabled={submitting}>
{submitting ? 'Creating account…' : 'Create administrator account'}
</Button>
</form>
<p className="text-center text-[11px] text-muted-foreground">
This screen is only available until the first administrator is created. After that,
subsequent users are added through Admin &rarr; Users.
</p>
</div>
</BrandedAuthShell>
);
}