page.tsx163 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 { DatabaseControls } from './database-controls';
7import { DeleteProjectButton } from './delete-project-button';
8import { ProjectGeneralForm } from './project-general-form';
9
10interface Project {
11 id: string;
12 name: string;
13 slug: string;
14 orgId: string;
15}
16
17interface Org {
18 id: string;
19 name: string;
20 personal: boolean;
21}
22
23type SaveResult = { ok: true } | { ok: false; error: string };
24
25export const dynamic = 'force-dynamic';
26
27function readApiMessage(body: string, fallback: string): string {
28 try {
29 const parsed = JSON.parse(body) as { message?: string };
30 if (parsed.message) return parsed.message;
31 } catch {
32 // body wasn't JSON — fall through to raw text
33 }
34 return body || fallback;
35}
36
37export default async function SettingsPage({ params }: { params: Promise<{ id: string }> }) {
38 const { id } = await params;
39 const [user, { project }, { orgs }] = await Promise.all([
40 requireUser(),
41 apiJson<{ project: Project }>(`/v1/projects/${id}`),
42 apiJson<{ orgs: Org[] }>('/v1/me/orgs').catch(() => ({ orgs: [] as Org[] })),
43 ]);
44 const otherOrgs = orgs.filter((o) => o.id !== project.orgId);
45
46 async function update(patch: { name?: string; slug?: string }): Promise<SaveResult> {
47 'use server';
48 const { id } = await params;
49 const res = await apiFetch(`/v1/projects/${id}`, {
50 method: 'PATCH',
51 headers: { 'content-type': 'application/json' },
52 body: JSON.stringify(patch),
53 });
54 if (!res.ok) {
55 const body = await res.text().catch(() => '');
56 return { ok: false, error: readApiMessage(body, `update failed: ${res.status}`) };
57 }
58 revalidatePath(`/dashboard/projects/${id}`);
59 return { ok: true };
60 }
61
62 async function moveProject(orgId: string): Promise<SaveResult> {
63 'use server';
64 const { id } = await params;
65 if (!orgId) return { ok: false, error: 'orgId is required' };
66 const res = await apiFetch(`/v1/projects/${id}/move`, {
67 method: 'POST',
68 headers: { 'content-type': 'application/json' },
69 body: JSON.stringify({ orgId }),
70 });
71 if (!res.ok) {
72 const body = await res.text().catch(() => '');
73 return { ok: false, error: readApiMessage(body, `move failed: ${res.status}`) };
74 }
75 revalidatePath(`/dashboard/projects/${id}`);
76 return { ok: true };
77 }
78
79 async function saveProfile(
80 patch: Record<string, string | null>,
81 ): Promise<SaveResult> {
82 'use server';
83 const res = await apiFetch('/v1/me', {
84 method: 'PATCH',
85 headers: { 'content-type': 'application/json' },
86 body: JSON.stringify(patch),
87 });
88 if (!res.ok) {
89 const body = await res.text().catch(() => '');
90 return { ok: false, error: readApiMessage(body, `update failed: ${res.status}`) };
91 }
92 revalidatePath(`/dashboard/projects/${id}/settings`);
93 return { ok: true };
94 }
95
96 return (
97 <div className="flex flex-col gap-8">
98 <ProjectGeneralForm
99 initial={{ name: project.name, slug: project.slug }}
100 update={update}
101 moveProject={moveProject}
102 otherOrgs={otherOrgs}
103 />
104
105 <section>
106 <ProfileBillingForm
107 initial={{
108 name: user.name ?? '',
109 legalName: user.legalName ?? '',
110 companyName: user.companyName ?? '',
111 companyRegistrationNumber: user.companyRegistrationNumber ?? '',
112 vatId: user.vatId ?? '',
113 addressLine1: user.addressLine1 ?? '',
114 addressLine2: user.addressLine2 ?? '',
115 addressCity: user.addressCity ?? '',
116 addressPostalCode: user.addressPostalCode ?? '',
117 addressRegion: user.addressRegion ?? '',
118 addressCountry: user.addressCountry ?? '',
119 dateOfBirth: user.dateOfBirth ?? '',
120 countryOfBirth: user.countryOfBirth ?? '',
121 timezone: user.timezone ?? '',
122 }}
123 currentImage={user.image}
124 displayName={user.legalName ?? user.name ?? user.email}
125 vatLocked={Boolean(user.vatVerifiedAt && user.vatId)}
126 save={saveProfile}
127 />
128 </section>
129
130 <section>
131 <h3 className="text-sm font-semibold text-[var(--color-text)]">Database</h3>
132 <p className="mt-1 text-xs text-[var(--color-text-muted)]">
133 Health of this project&apos;s database, plus recovery controls.
134 </p>
135 <div className="mt-4">
136 <DatabaseControls
137 projectId={id}
138 projectName={project.name}
139 apiOrigin={process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? ''}
140 />
141 </div>
142 </section>
143
144 <section>
145 <h3 className="text-sm font-semibold text-red-400">Danger zone</h3>
146 <div className="mt-4 flex items-start justify-between gap-4 rounded-lg border border-red-400/30 bg-red-400/5 p-5">
147 <div>
148 <p className="text-sm text-[var(--color-text)]">Delete this project</p>
149 <p className="mt-1 text-xs text-[var(--color-text-muted)]">
150 Soft-deletes the project. The schema and data are retained for 30 days so you can
151 recover it, then permanently deleted.
152 </p>
153 </div>
154 <DeleteProjectButton
155 projectId={id}
156 projectName={project.name}
157 apiOrigin={process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? ''}
158 />
159 </div>
160 </section>
161 </div>
162 );
163}