diff --git a/apps/oss/src/components/settings/delete-organization-card.tsx b/apps/oss/src/components/settings/delete-organization-card.tsx new file mode 100644 index 00000000..da139fd9 --- /dev/null +++ b/apps/oss/src/components/settings/delete-organization-card.tsx @@ -0,0 +1,97 @@ +import { Link } from "@tanstack/react-router" +import { useState } from "react" +import { switchActiveOrganization } from "../../lib/active-organization" +import { authClient } from "../../lib/auth-client" +import { invalidateAppLayoutSession } from "../../lib/app-layout-session" +import { formatDate } from "../../lib/i18n/format" +import { useI18n } from "../../lib/i18n/react" +import { loadPage } from "../../lib/page-navigation" +import { handledMutationError } from "../../lib/query-refresh" +import { trpc } from "../../trpc/client" +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../ui/alert-dialog" +import { Button } from "../ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" +import { Input } from "../ui/input" +import { Label } from "../ui/label" + +/** Permanently deletes the organization after the admin types its name. The server checks both again. */ +export function DeleteOrganizationCard({ organizationName }: { organizationName: string }) { + const { t, locale } = useI18n() + const [confirmName, setConfirmName] = useState("") + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [scheduledFor, setScheduledFor] = useState(null) + + async function deleteOrganization() { + setBusy(true) + setError(null) + try { + const outcome = await trpc.settings.deleteOrganization.mutate({ confirmName }, handledMutationError) + if (outcome.status === "scheduled") { + setScheduledFor(outcome.deleteAfter) + setBusy(false) + return + } + if (outcome.nextOrganizationId) { + await switchActiveOrganization(outcome.nextOrganizationId) + return + } + // The user may have been deleted with the organization, which already ended the session. + await authClient.signOut().catch(() => undefined) + invalidateAppLayoutSession() + loadPage("/login") + } catch { + setError(t("settings.deleteOrganization.failed")) + setBusy(false) + } + } + + return ( + + + {t("settings.deleteOrganization.title")} + {t("settings.deleteOrganization.description", { name: organizationName })} + + + + {t("settings.deleteOrganization.export")} + + {scheduledFor ? ( +

{t("settings.deleteOrganization.scheduled", { date: formatDate(scheduledFor, locale) })}

+ ) : ( + { if (!open) { setConfirmName(""); setError(null) } }}> + + + + + + {t("settings.deleteOrganization.confirm.title", { name: organizationName })} + {t("settings.deleteOrganization.confirm.description")} + +
+ + setConfirmName(event.target.value)} /> + {error &&

{error}

} +
+ + {t("settings.deleteOrganization.confirm.cancel")} + + +
+
+ )} +
+
+ ) +} diff --git a/apps/oss/src/domain/organization-deletion.ts b/apps/oss/src/domain/organization-deletion.ts new file mode 100644 index 00000000..f5db88b5 --- /dev/null +++ b/apps/oss/src/domain/organization-deletion.ts @@ -0,0 +1,14 @@ +import { getRuntimeExtensions, type OrganizationDeletionOutcome, type OrganizationDeletionRequest } from "../lib/runtime/extensions" +import { purgeOrganization } from "./organization-purge" + +/** + * Carries out "Delete organisation" once the caller has checked the admin and the typed name. A + * distribution's `requestOrganizationDeletion` decides what happens; without one the organization is + * purged now. + */ +export async function requestOrganizationDeletion(request: OrganizationDeletionRequest): Promise { + const extension = getRuntimeExtensions().find(candidate => candidate.requestOrganizationDeletion) + if (extension?.requestOrganizationDeletion) return extension.requestOrganizationDeletion(request) + await purgeOrganization(request.organizationId) + return { status: "deleted" } +} diff --git a/apps/oss/src/lib/documents/__tests__/pdf-access.integration.test.ts b/apps/oss/src/lib/documents/__tests__/pdf-access.integration.test.ts index 33232eb8..a756012e 100644 --- a/apps/oss/src/lib/documents/__tests__/pdf-access.integration.test.ts +++ b/apps/oss/src/lib/documents/__tests__/pdf-access.integration.test.ts @@ -7,6 +7,9 @@ import { setRuntimeServices, resetRuntimeServices, type RenderInput } from "../. import { privateDocumentPdf, publicInvoicePdf } from "../pdf-access" import { issueDocument } from "../../../application/issuance" import { signInvoicePaymentToken } from "../../payments/public" +import { loadPublicInvoiceByToken } from "../../payments/public-access" +import { billingProvider } from "../../billing" +import { exportAccounting } from "../../exports/accounting" const cleanups: Array<() => Promise> = [] const bytes = new Map() @@ -86,4 +89,22 @@ const request = new Request("http://quits.test/download") await prisma.invoice.update({ where: { id: invoice.id }, data: { publicPaymentKeyVersion: { increment: 1 } } }) expect((await publicInvoicePdf(token)).status).toBe(404) }) + it("keeps PDFs, public links and exports available while the organization is read-only", async () => { + const { org, invoice } = await setup() + const sent = await issueDocument({ kind: "invoice", actor: org.actors.admin, commandInput: { id: invoice.id, allowSendWithoutEmail: true }, clientRequestId: "read-only" }) + expect(sent, JSON.stringify(sent)).toMatchObject({ status: "completed" }) + const issued = await prisma.invoice.update({ where: { id: invoice.id }, data: { publicPaymentIssuedAt: new Date() } }) + setRuntimeServices({ billingProvider: { + getSubscription: async () => ({ status: "canceled", priceId: null, organizationAccess: { state: "read_only", reason: "canceled" }, + access: { paidOnly: false, hasSubscription: false, checkoutAvailable: false, portalAvailable: false } }), + assertInvoiceCreationAllowed: async () => {}, + } }) + await expect(billingProvider.assertInvoiceCreationAllowed(org.organizationId)).rejects.toMatchObject({ code: "FORBIDDEN" }) + expect((await privateDocumentPdf(request, "invoice", invoice.id)).headers.get("X-Quits-Artifact")).toBe("stored") + const token = signInvoicePaymentToken({ invoiceId: invoice.id, keyVersion: issued.publicPaymentKeyVersion, scope: "invoice_payment" }, "synthetic-download-link-secret") + expect((await publicInvoicePdf(token)).headers.get("X-Quits-Artifact")).toBe("stored") + expect(await loadPublicInvoiceByToken(token, "synthetic-download-link-secret")).toMatchObject({ invoice: { id: invoice.id } }) + const today = new Date().toISOString().slice(0, 10) + expect((await exportAccounting(org.organizationId, { dataset: "invoices", from: today, to: today })).csv).toContain(issued.number!) + }) }) diff --git a/apps/oss/src/lib/i18n/catalog/da/settings.ts b/apps/oss/src/lib/i18n/catalog/da/settings.ts index f4c3eae6..456e7b08 100644 --- a/apps/oss/src/lib/i18n/catalog/da/settings.ts +++ b/apps/oss/src/lib/i18n/catalog/da/settings.ts @@ -284,4 +284,15 @@ export const daSettingsMessages = { "settings.row.done": "Færdig", "settings.row.empty": "Ikke angivet", "settings.row.vat": "Momsregistrering", + "settings.deleteOrganization.title": "Slet organisation", + "settings.deleteOrganization.description": "Sletter {name} permanent med dokumenter, kunder og betalinger og de brugere, der ikke er med i en anden organisation. Det kan ikke fortrydes. Du skal selv opbevare dine fakturaer og kreditnotaer i 5 år, så hent dine data først.", + "settings.deleteOrganization.export": "Hent dine data", + "settings.deleteOrganization.action": "Slet organisation", + "settings.deleteOrganization.confirm.title": "Slet {name} permanent?", + "settings.deleteOrganization.confirm.description": "Alt i organisationen bliver slettet og kan ikke gendannes.", + "settings.deleteOrganization.confirm.label": "Skriv {name} for at bekræfte", + "settings.deleteOrganization.confirm.cancel": "Annuller", + "settings.deleteOrganization.confirm.submit": "Slet permanent", + "settings.deleteOrganization.failed": "Kunne ikke slette organisationen. Tjek navnet, og prøv igen.", + "settings.deleteOrganization.scheduled": "Organisationen bliver slettet den {date}.", } as const diff --git a/apps/oss/src/lib/i18n/catalog/en/settings.ts b/apps/oss/src/lib/i18n/catalog/en/settings.ts index 8298617e..d9ac91be 100644 --- a/apps/oss/src/lib/i18n/catalog/en/settings.ts +++ b/apps/oss/src/lib/i18n/catalog/en/settings.ts @@ -282,4 +282,15 @@ export const enSettingsMessages = { "settings.row.done": "Done", "settings.row.empty": "Not set", "settings.row.vat": "VAT registration", + "settings.deleteOrganization.title": "Delete organization", + "settings.deleteOrganization.description": "Permanently deletes {name} with its documents, customers and payments, and the users who belong to no other organization. This cannot be undone. You must keep your invoices and credit notes for 5 years, so export your data first.", + "settings.deleteOrganization.export": "Export your data", + "settings.deleteOrganization.action": "Delete organization", + "settings.deleteOrganization.confirm.title": "Delete {name} permanently?", + "settings.deleteOrganization.confirm.description": "Everything in the organization is deleted and cannot be restored.", + "settings.deleteOrganization.confirm.label": "Type {name} to confirm", + "settings.deleteOrganization.confirm.cancel": "Cancel", + "settings.deleteOrganization.confirm.submit": "Delete permanently", + "settings.deleteOrganization.failed": "Could not delete the organization. Check the name and try again.", + "settings.deleteOrganization.scheduled": "The organization will be deleted on {date}.", } as const diff --git a/apps/oss/src/lib/runtime/auth-config.ts b/apps/oss/src/lib/runtime/auth-config.ts index 3a9a4884..49839845 100644 --- a/apps/oss/src/lib/runtime/auth-config.ts +++ b/apps/oss/src/lib/runtime/auth-config.ts @@ -102,7 +102,8 @@ export function buildQuitsAuthOptions(input: { const organizationPlugin = organization({ ac, roles: { admin, member, accountant }, - // Organization deletion must not remove financial records or their evidence. + // Better Auth would delete only some rows. Settings deletes through `requestOrganizationDeletion`, + // which ends in `purgeOrganization`, so this endpoint stays closed. disableOrganizationDeletion: true, allowUserToCreateOrganization: true, organizationHooks: { diff --git a/apps/oss/src/lib/runtime/extensions.ts b/apps/oss/src/lib/runtime/extensions.ts index 324c645a..e8d2b4ca 100644 --- a/apps/oss/src/lib/runtime/extensions.ts +++ b/apps/oss/src/lib/runtime/extensions.ts @@ -36,6 +36,14 @@ export type OrganizationPurgeContext = { userIds: readonly string[] } +export type OrganizationDeletionRequest = { + organizationId: string + /** The admin who confirmed the deletion by typing the organization's name. */ + userId: string +} +/** `deleted`: the organization is gone now. `scheduled`: it will be deleted at `deleteAfter`. */ +export type OrganizationDeletionOutcome = { status: "deleted" } | { status: "scheduled"; deleteAfter: Date } + export type RuntimeExtension = { id: string /** Hosted runtimes opt in after resolving existing duplicate CVRs. Ignored by self-host. */ @@ -76,6 +84,13 @@ export type RuntimeExtension = { * Self-hosted runtimes leave this unset. */ onOrganizationPurge?: (tx: OrganizationPurgeTransaction, context: OrganizationPurgeContext) => Promise + /** + * Decides what "Delete organisation" in Settings does, after the app has checked that the user is + * an admin and typed the organization's name. The first extension that sets this replaces the + * self-hosted default, which purges at once. A scheduled deletion must still end in + * `purgeOrganization`. Self-hosted runtimes leave this unset. + */ + requestOrganizationDeletion?: (request: OrganizationDeletionRequest) => Promise } const DEFAULT_MAX_PROMPT_CHARS = 4000 @@ -91,6 +106,7 @@ function mergeCapabilities( return { sellerVatConfirmation: { enabled: base.sellerVatConfirmation?.enabled ?? false, ...patch.sellerVatConfirmation }, + organizationDeletion: { enabled: base.organizationDeletion?.enabled ?? false, ...patch.organizationDeletion }, documents: { ...base.documents, ...patch.documents }, agreements: { ...base.agreements, ...patch.agreements }, aiInvoiceDraft: { @@ -142,6 +158,8 @@ function readDefaultCapabilities( return { onboardingV2: { enabled: false, nonVatEnabled: false }, sellerVatConfirmation: { enabled: false }, + // Hosted turns it on once its extension decides what deletion does there. + organizationDeletion: { enabled: !isCloud }, documents: { artifactsRequired: true }, agreements: { depositsEnabled: readBooleanEnv(readProductEnv(env, "DEPOSITS_ENABLED"), true) }, aiInvoiceDraft: { diff --git a/apps/oss/src/routes/_app/settings.$section.tsx b/apps/oss/src/routes/_app/settings.$section.tsx index e42ba148..d3ef0919 100644 --- a/apps/oss/src/routes/_app/settings.$section.tsx +++ b/apps/oss/src/routes/_app/settings.$section.tsx @@ -71,6 +71,8 @@ import type { VatPeriod } from "../../lib/vat-deadline" import { AccountingIntegrationsCard } from "../../components/settings/accounting-integrations-card" import { AgentKeysCard } from "../../components/settings/agent-keys-card" import { AuditLogCard } from "../../components/settings/audit-log-card" +import { DeleteOrganizationCard } from "../../components/settings/delete-organization-card" +import { parseOrganizationRoles } from "../../domain/permissions" import { PaymentDetailsCard } from "../../components/settings/payment-details-card" import { ReminderPolicyCard } from "../../components/settings/reminder-policy-card" import { useRuntimeUi } from "../../lib/runtime/ui" @@ -282,6 +284,7 @@ function SettingsPage() { const [sellerVatChoiceReviewed, setSellerVatChoiceReviewed] = useState(false) const [withdrawSellerVatEvidence, setWithdrawSellerVatEvidence] = useState(false) const [nonVatEnabled, setNonVatEnabled] = useState(false) + const [organizationDeletionEnabled, setOrganizationDeletionEnabled] = useState(false) const [sellerVatConfirmationEnabled, setSellerVatConfirmationEnabled] = useState(null) const [capabilitiesFailed, setCapabilitiesFailed] = useState(false) const [capabilitiesAttempt, setCapabilitiesAttempt] = useState(0) @@ -331,6 +334,7 @@ function SettingsPage() { // Team members state const [organizationSlug, setOrganizationSlug] = useState("") const [savedOrganizationSlug, setSavedOrganizationSlug] = useState(null) + const [organizationName, setOrganizationName] = useState("") const [slugSaving, setSlugSaving] = useState(false) const [slugSaved, setSlugSaved] = useState(false) const [slugError, setSlugError] = useState(null) @@ -454,6 +458,7 @@ function SettingsPage() { setAiCapabilities((data as Pick).aiInvoiceDraft) setSellerVatConfirmationEnabled(data.sellerVatConfirmation?.enabled === true) setNonVatEnabled(data.onboardingV2?.nonVatEnabled === true) + setOrganizationDeletionEnabled(data.organizationDeletion?.enabled === true) }) .catch(() => setCapabilitiesFailed(true)) .finally(() => setLoadingAiCapabilities(false)) @@ -514,6 +519,7 @@ function SettingsPage() { const slug = typeof org.data.slug === "string" ? org.data.slug : "" setOrganizationSlug(slug) setSavedOrganizationSlug(slug) + setOrganizationName(org.data.name) setMembers((org.data.members ?? []) as OrgMember[]) setInvitations( ((org.data.invitations ?? []) as OrgInvitation[]).filter( @@ -1175,6 +1181,9 @@ function SettingsPage() { locale={displayLocale} /> )} + {organizationDeletionEnabled && organizationName && parseOrganizationRoles(members.find(m => m.user.id === session?.user.id)?.role).includes("admin") && ( + + )}
diff --git a/apps/oss/src/trpc/routers/__tests__/settings-delete-organization.integration.test.ts b/apps/oss/src/trpc/routers/__tests__/settings-delete-organization.integration.test.ts new file mode 100644 index 00000000..668a3baf --- /dev/null +++ b/apps/oss/src/trpc/routers/__tests__/settings-delete-organization.integration.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { prisma } from "../../../lib/db" +import { setRuntimeExtensions } from "../../../lib/runtime/extensions" +import { ensureTestMembership } from "../../../test-utils/membership" +import { createTestOrganization, hasTestDatabase } from "../../../test-utils/organization" +import { appRouter } from "../../router" + +;(hasTestDatabase ? describe : describe.skip)("settings.deleteOrganization", () => { + const cleanups: Array<() => Promise> = [] + afterEach(async () => { setRuntimeExtensions([]); while (cleanups.length) await cleanups.pop()?.() }) + + async function setup() { + const org = await createTestOrganization({ roles: ["admin", "member", "accountant"] }) + cleanups.push(org.cleanup) + const { name } = await prisma.organization.findUniqueOrThrow({ where: { id: org.organizationId } }) + const caller = (role: "admin" | "member" | "accountant") => appRouter.createCaller({ session: { + user: { id: org.actors[role].userId, email: `${role}@example.test`, name: role }, + session: { activeOrganizationId: org.organizationId }, + } } as never) + const exists = async () => Boolean(await prisma.organization.findUnique({ where: { id: org.organizationId } })) + return { org, name, caller, exists } + } + + it("refuses members, accountants and a mistyped name", async () => { + const { name, caller, exists } = await setup() + for (const role of ["member", "accountant"] as const) { + await expect(caller(role).settings.deleteOrganization({ confirmName: name })).rejects.toMatchObject({ code: "FORBIDDEN" }) + } + await expect(caller("admin").settings.deleteOrganization({ confirmName: `${name} typo` })).rejects.toMatchObject({ code: "BAD_REQUEST" }) + expect(await exists()).toBe(true) + }) + + it("refuses when a distribution switches the capability off", async () => { + const { name, caller, exists } = await setup() + setRuntimeExtensions([{ id: "hide-deletion", resolveCapabilities: () => ({ organizationDeletion: { enabled: false } }) }]) + await expect(caller("admin").settings.deleteOrganization({ confirmName: name })).rejects.toMatchObject({ code: "FORBIDDEN" }) + expect(await exists()).toBe(true) + }) + + it("purges at once without an extension and sends the admin to their other organization", async () => { + const { org, name, caller, exists } = await setup() + const other = await createTestOrganization() + cleanups.push(other.cleanup) + await ensureTestMembership(other.organizationId, org.actors.admin.userId, "member") + await expect(caller("admin").settings.deleteOrganization({ confirmName: ` ${name} ` })) + .resolves.toEqual({ status: "deleted", nextOrganizationId: other.organizationId }) + expect(await exists()).toBe(false) + expect(await prisma.user.findUnique({ where: { id: org.actors.member.userId } })).toBeNull() + expect(await prisma.user.findUnique({ where: { id: org.actors.admin.userId } })).not.toBeNull() + }) + + it("lets an extension replace the purge", async () => { + const { org, name, caller, exists } = await setup() + const deleteAfter = new Date("2099-01-08T00:00:00Z") + const requestOrganizationDeletion = vi.fn(async () => ({ status: "scheduled" as const, deleteAfter })) + setRuntimeExtensions([{ id: "scheduled-deletion", requestOrganizationDeletion }]) + await expect(caller("admin").settings.deleteOrganization({ confirmName: name })) + .resolves.toEqual({ status: "scheduled", deleteAfter, nextOrganizationId: null }) + expect(requestOrganizationDeletion).toHaveBeenCalledWith({ organizationId: org.organizationId, userId: org.actors.admin.userId }) + expect(await exists()).toBe(true) + }) +}) diff --git a/apps/oss/src/trpc/routers/settings.ts b/apps/oss/src/trpc/routers/settings.ts index bd030cbe..1e1a730b 100644 --- a/apps/oss/src/trpc/routers/settings.ts +++ b/apps/oss/src/trpc/routers/settings.ts @@ -37,6 +37,7 @@ import { encryptSecret } from "../../lib/secrets" import { getStripePaymentConfigurationState, resolveStripeCheckoutTarget, stripeByokConfigured, usesStripeConnectCheckout } from "../../lib/payments/stripe" import { appLogger } from "../../lib/observability" import { actorCan } from "../../domain/actor" +import { requestOrganizationDeletion } from "../../domain/organization-deletion" import { getRuntimeCapabilities, isNonVatRegistrationAllowed } from "../../lib/runtime/extensions" import { getRuntimeEnv, getRuntimePlatform } from "../../lib/runtime/platform" import { @@ -831,6 +832,26 @@ export const settingsRouter = router({ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Could not update Stripe Connect. Please try again." }) } }), + + /** + * "Delete organisation". Only a signed-in admin, never an agent key, and only after typing the + * organization's name. Returns where the user goes next when the organization is already gone. + */ + deleteOrganization: authorizedProcedure("settings:update") + .input(z.object({ confirmName: z.string().max(200) }).strict()) + .mutation(async ({ ctx, input }) => { + if (!getRuntimeCapabilities().organizationDeletion.enabled || ctx.actor.kind !== "user") { + throw new TRPCError({ code: "FORBIDDEN", message: "Organization deletion is not available" }) + } + const organization = await prisma.organization.findUniqueOrThrow({ where: { id: ctx.organizationId }, select: { name: true } }) + if (input.confirmName.trim() !== organization.name.trim()) { + throw new TRPCError({ code: "BAD_REQUEST", message: "The name does not match the organization" }) + } + const outcome = await requestOrganizationDeletion({ organizationId: ctx.organizationId, userId: ctx.user.id }) + if (outcome.status !== "deleted") return { ...outcome, nextOrganizationId: null } + const next = await prisma.member.findFirst({ where: { userId: ctx.user.id }, orderBy: { createdAt: "asc" }, select: { organizationId: true } }) + return { ...outcome, nextOrganizationId: next?.organizationId ?? null } + }), }) /** diff --git a/packages/contracts/src/runtime.ts b/packages/contracts/src/runtime.ts index 18752b4e..4d5eba7a 100644 --- a/packages/contracts/src/runtime.ts +++ b/packages/contracts/src/runtime.ts @@ -46,6 +46,9 @@ export const documentsCapabilitiesSchema = z.object({ artifactsRequired: z.boole export const agreementCapabilitiesSchema = z.object({ depositsEnabled: z.boolean() }).strict() +/** Whether admins see "Delete organisation" in Settings. On for self-host; a distribution can change it. */ +export const organizationDeletionCapabilitiesSchema = z.object({ enabled: z.boolean() }).strict() + export const runtimeCapabilitiesSchema = z .object({ documents: documentsCapabilitiesSchema, @@ -54,6 +57,7 @@ export const runtimeCapabilitiesSchema = z onboardingAi: onboardingAiCapabilitiesSchema, onboardingV2: z.object({ enabled: z.boolean(), nonVatEnabled: z.boolean().optional() }).strict().optional(), sellerVatConfirmation: z.object({ enabled: z.boolean() }).strict().default({ enabled: false }), + organizationDeletion: organizationDeletionCapabilitiesSchema.default({ enabled: false }), payments: paymentsCapabilitiesSchema, emailDelivery: emailDeliveryCapabilitiesSchema, }) @@ -67,6 +71,7 @@ export const runtimeCapabilityPatchSchema = z onboardingAi: onboardingAiCapabilitiesSchema.partial().optional(), onboardingV2: z.object({ enabled: z.boolean().optional(), nonVatEnabled: z.boolean().optional() }).strict().optional(), sellerVatConfirmation: z.object({ enabled: z.boolean().optional() }).strict().optional(), + organizationDeletion: organizationDeletionCapabilitiesSchema.partial().optional(), payments: paymentsCapabilitiesSchema.partial().optional(), emailDelivery: emailDeliveryCapabilitiesSchema.partial().optional(), })