page.tsx163 lines · main
| 1 | import { revalidatePath } from 'next/cache'; |
| 2 | |
| 3 | import { ProfileBillingForm } from '../../../../../../components/profile-billing-form'; |
| 4 | import { apiFetch, apiJson } from '../../../../../../lib/api'; |
| 5 | import { requireUser } from '../../../../../../lib/session'; |
| 6 | import { DatabaseControls } from './database-controls'; |
| 7 | import { DeleteProjectButton } from './delete-project-button'; |
| 8 | import { ProjectGeneralForm } from './project-general-form'; |
| 9 | |
| 10 | interface Project { |
| 11 | id: string; |
| 12 | name: string; |
| 13 | slug: string; |
| 14 | orgId: string; |
| 15 | } |
| 16 | |
| 17 | interface Org { |
| 18 | id: string; |
| 19 | name: string; |
| 20 | personal: boolean; |
| 21 | } |
| 22 | |
| 23 | type SaveResult = { ok: true } | { ok: false; error: string }; |
| 24 | |
| 25 | export const dynamic = 'force-dynamic'; |
| 26 | |
| 27 | function 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 | |
| 37 | export 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'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 | } |