page.tsx211 lines · main
1import { revalidatePath } from 'next/cache';
2
3import { ProfileBillingForm } from '../../../../components/profile-billing-form';
4import { apiFetch, apiJson } from '../../../../lib/api';
5import { requireUser } from '../../../../lib/session';
6import { ChangeEmailForm } from './change-email-form';
7import { DeleteAccountForm } from './delete-account-form';
8
9interface PendingInvitation {
10 id: string;
11 projectId: string;
12 role: string;
13 invitedBy: string | null;
14 expiresAt: string;
15}
16
17export const dynamic = 'force-dynamic';
18
19function formatNearBy(
20 nearBy: { city: string | null; region: string | null; country: string | null } | null,
21): string {
22 if (!nearBy) return '—';
23 const place = nearBy.city ?? nearBy.region;
24 if (place && nearBy.country) return `${place}, ${nearBy.country}`;
25 return place ?? nearBy.country ?? '—';
26}
27
28export default async function SettingsPage() {
29 const user = await requireUser();
30
31 const { invitations } = await apiJson<{ invitations: PendingInvitation[] }>(
32 '/v1/me/invitations',
33 ).catch(() => ({ invitations: [] as PendingInvitation[] }));
34
35 async function save(
36 patch: Record<string, string | null>,
37 ): Promise<{ ok: true } | { ok: false; error: string }> {
38 'use server';
39 const res = await apiFetch('/v1/me', {
40 method: 'PATCH',
41 headers: { 'content-type': 'application/json' },
42 body: JSON.stringify(patch),
43 });
44 if (!res.ok) {
45 // Never throw from a server action on an expected user-visible error
46 // (VAT-locked, VAT-invalid, validation). Throwing crosses the RSC
47 // boundary and Next.js renders its generic error boundary instead
48 // of the form's inline error. Return a result object so the client
49 // can render the message cleanly.
50 const body = await res.text().catch(() => '');
51 let message = body;
52 try {
53 const parsed = JSON.parse(body) as { message?: string };
54 if (parsed.message) message = parsed.message;
55 } catch {
56 // body wasn't JSON — fall back to the raw text
57 }
58 return { ok: false, error: message || `update failed: ${res.status}` };
59 }
60 revalidatePath('/dashboard/settings');
61 return { ok: true };
62 }
63
64 return (
65 <div className="flex max-w-3xl flex-col gap-8 pb-12">
66 <header>
67 <h1 className="text-3xl font-bold tracking-tight text-[var(--color-text)]">Settings</h1>
68 <p className="mt-1 text-sm text-[var(--color-text-muted)]">
69 Account and notification preferences.
70 </p>
71 </header>
72
73 <section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
74 <h2 className="text-base font-semibold text-[var(--color-text)]">Account</h2>
75 <div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
76 <div className="flex flex-col gap-1">
77 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
78 Email
79 </span>
80 <span className="text-sm text-[var(--color-text)]">
81 {user.email}
82 {user.emailVerified ? (
83 <span className="ml-2 rounded bg-[var(--color-primary-subtle)] px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-[var(--color-primary)]">
84 verified
85 </span>
86 ) : (
87 <span className="ml-2 rounded bg-red-400/15 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-red-400">
88 unverified
89 </span>
90 )}
91 </span>
92 </div>
93 <div className="flex flex-col gap-1">
94 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
95 Name
96 </span>
97 <span className="text-sm text-[var(--color-text)]">
98 {user.legalName ?? user.name ?? '—'}
99 </span>
100 </div>
101 </div>
102 <p className="mt-4 text-xs text-[var(--color-text-muted)]">
103 Email is used for sign-in and alerts. To change it, click below — we&apos;ll send a
104 confirmation link to your current address.
105 </p>
106 <ChangeEmailForm
107 currentEmail={user.email}
108 apiOrigin={process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? ''}
109 />
110 {user.isAdmin ? (
111 <p className="mt-3 text-xs">
112 <span className="rounded bg-[var(--color-primary-subtle)] px-2 py-0.5 text-[10px] uppercase tracking-wider text-[var(--color-primary)]">
113 Platform admin
114 </span>
115 <a
116 href="https://admin.briven.tech"
117 className="ml-3 text-[var(--color-text-link)] hover:underline"
118 >
119 open admin →
120 </a>
121 </p>
122 ) : null}
123 </section>
124
125 <section>
126 <ProfileBillingForm
127 initial={{
128 name: user.name ?? '',
129 legalName: user.legalName ?? '',
130 companyName: user.companyName ?? '',
131 companyRegistrationNumber: user.companyRegistrationNumber ?? '',
132 vatId: user.vatId ?? '',
133 addressLine1: user.addressLine1 ?? '',
134 addressLine2: user.addressLine2 ?? '',
135 addressCity: user.addressCity ?? '',
136 addressPostalCode: user.addressPostalCode ?? '',
137 addressRegion: user.addressRegion ?? '',
138 addressCountry: user.addressCountry ?? '',
139 dateOfBirth: user.dateOfBirth ?? '',
140 countryOfBirth: user.countryOfBirth ?? '',
141 timezone: user.timezone ?? '',
142 }}
143 currentImage={user.image}
144 displayName={user.legalName ?? user.name ?? user.email}
145 vatLocked={Boolean(user.vatVerifiedAt && user.vatId)}
146 save={save}
147 />
148 </section>
149
150 <section>
151 <h2 className="font-mono text-sm text-[var(--color-text)]">last sign-in</h2>
152 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
153 under EU GDPR you have the right to see the metadata we store about your sign-in activity.
154 visible only to you.
155 </p>
156 <dl className="mt-3 grid grid-cols-1 gap-x-3 sm:grid-cols-[160px_1fr] gap-y-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-5 font-mono text-sm">
157 <dt className="text-[var(--color-text-subtle)]">at</dt>
158 <dd>
159 {user.lastSignIn
160 ? new Date(user.lastSignIn.at).toISOString().replace('T', ' ').slice(0, 19)
161 : 'never'}
162 </dd>
163
164 <dt className="text-[var(--color-text-subtle)]">near by</dt>
165 <dd>{formatNearBy(user.lastSignIn?.nearBy ?? null)}</dd>
166
167 <dt className="text-[var(--color-text-subtle)]">ip address</dt>
168 <dd>{user.lastSignIn?.ipAddress ?? '—'}</dd>
169
170 <dt className="text-[var(--color-text-subtle)]">user agent</dt>
171 <dd className="break-words text-xs text-[var(--color-text-muted)]">
172 {user.lastSignIn?.userAgent ?? '—'}
173 </dd>
174 </dl>
175 </section>
176
177 <section>
178 <h2 className="font-mono text-sm text-[var(--color-text)]">pending invitations</h2>
179 {invitations.length === 0 ? (
180 <p className="mt-3 rounded-md border border-dashed border-[var(--color-border)] p-6 text-center font-mono text-sm text-[var(--color-text-muted)]">
181 no pending invitations.
182 </p>
183 ) : (
184 <ul className="mt-3 flex flex-col gap-2">
185 {invitations.map((inv) => (
186 <li
187 key={inv.id}
188 className="flex items-center justify-between rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3 font-mono text-sm"
189 >
190 <div>
191 <p>
192 invited to <code>{inv.projectId}</code> as{' '}
193 <span className="text-[var(--color-primary)]">{inv.role}</span>
194 </p>
195 <p className="mt-0.5 text-xs text-[var(--color-text-subtle)]">
196 expires {new Date(inv.expiresAt).toISOString().slice(0, 10)}
197 </p>
198 </div>
199 </li>
200 ))}
201 </ul>
202 )}
203 </section>
204
205 <section>
206 <h2 className="font-mono text-sm text-red-400">danger zone</h2>
207 <DeleteAccountForm email={user.email} />
208 </section>
209 </div>
210 );
211}