Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions apps/oss/src/components/settings/delete-organization-card.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)
const [scheduledFor, setScheduledFor] = useState<Date | null>(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 (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle>{t("settings.deleteOrganization.title")}</CardTitle>
<CardDescription>{t("settings.deleteOrganization.description", { name: organizationName })}</CardDescription>
</CardHeader>
<CardContent className="grid justify-items-start gap-3">
<Link to="/activity" search={{ tab: "accounting" }} className="text-sm underline underline-offset-4">
{t("settings.deleteOrganization.export")}
</Link>
{scheduledFor ? (
<p role="status" className="text-sm">{t("settings.deleteOrganization.scheduled", { date: formatDate(scheduledFor, locale) })}</p>
) : (
<AlertDialog onOpenChange={open => { if (!open) { setConfirmName(""); setError(null) } }}>
<AlertDialogTrigger asChild>
<Button type="button" variant="destructive">{t("settings.deleteOrganization.action")}</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("settings.deleteOrganization.confirm.title", { name: organizationName })}</AlertDialogTitle>
<AlertDialogDescription>{t("settings.deleteOrganization.confirm.description")}</AlertDialogDescription>
</AlertDialogHeader>
<div className="grid gap-2">
<Label htmlFor="delete-organization-name">{t("settings.deleteOrganization.confirm.label", { name: organizationName })}</Label>
<Input id="delete-organization-name" autoComplete="off" value={confirmName} onChange={event => setConfirmName(event.target.value)} />
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>
<AlertDialogFooter>
<AlertDialogCancel type="button" disabled={busy}>{t("settings.deleteOrganization.confirm.cancel")}</AlertDialogCancel>
<Button type="button" variant="destructive" disabled={busy || confirmName.trim() !== organizationName.trim()} onClick={() => void deleteOrganization()}>
{t("settings.deleteOrganization.confirm.submit")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</CardContent>
</Card>
)
}
14 changes: 14 additions & 0 deletions apps/oss/src/domain/organization-deletion.ts
Original file line number Diff line number Diff line change
@@ -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<OrganizationDeletionOutcome> {
const extension = getRuntimeExtensions().find(candidate => candidate.requestOrganizationDeletion)
if (extension?.requestOrganizationDeletion) return extension.requestOrganizationDeletion(request)
await purgeOrganization(request.organizationId)
return { status: "deleted" }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>> = []
const bytes = new Map<string, Uint8Array>()
Expand Down Expand Up @@ -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!)
})
})
11 changes: 11 additions & 0 deletions apps/oss/src/lib/i18n/catalog/da/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions apps/oss/src/lib/i18n/catalog/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion apps/oss/src/lib/runtime/auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
18 changes: 18 additions & 0 deletions apps/oss/src/lib/runtime/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -76,6 +84,13 @@ export type RuntimeExtension = {
* Self-hosted runtimes leave this unset.
*/
onOrganizationPurge?: (tx: OrganizationPurgeTransaction, context: OrganizationPurgeContext) => Promise<void>
/**
* 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<OrganizationDeletionOutcome>
}

const DEFAULT_MAX_PROMPT_CHARS = 4000
Expand All @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
9 changes: 9 additions & 0 deletions apps/oss/src/routes/_app/settings.$section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<boolean | null>(null)
const [capabilitiesFailed, setCapabilitiesFailed] = useState(false)
const [capabilitiesAttempt, setCapabilitiesAttempt] = useState(0)
Expand Down Expand Up @@ -331,6 +334,7 @@ function SettingsPage() {
// Team members state
const [organizationSlug, setOrganizationSlug] = useState("")
const [savedOrganizationSlug, setSavedOrganizationSlug] = useState<string | null>(null)
const [organizationName, setOrganizationName] = useState("")
const [slugSaving, setSlugSaving] = useState(false)
const [slugSaved, setSlugSaved] = useState(false)
const [slugError, setSlugError] = useState<string | null>(null)
Expand Down Expand Up @@ -454,6 +458,7 @@ function SettingsPage() {
setAiCapabilities((data as Pick<RuntimeCapabilities, "aiInvoiceDraft">).aiInvoiceDraft)
setSellerVatConfirmationEnabled(data.sellerVatConfirmation?.enabled === true)
setNonVatEnabled(data.onboardingV2?.nonVatEnabled === true)
setOrganizationDeletionEnabled(data.organizationDeletion?.enabled === true)
})
.catch(() => setCapabilitiesFailed(true))
.finally(() => setLoadingAiCapabilities(false))
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1175,6 +1181,9 @@ function SettingsPage() {
locale={displayLocale}
/>
)}
{organizationDeletionEnabled && organizationName && parseOrganizationRoles(members.find(m => m.user.id === session?.user.id)?.role).includes("admin") && (
<DeleteOrganizationCard organizationName={organizationName} />
)}
</SettingsPane>

<form id={settingsFormId} onSubmit={handleSubmit} data-save-origin="form" className="grid grid-cols-[minmax(0,1fr)] gap-6">
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>> = []
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)
})
})
Loading
Loading