mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(settings): trim the export surface and remove the sharing page
Right-align the health-record export generate button. Remove the
duplicate doctor-report card from the export page (the doctor report is
reached through the health-record export now) and drop the now-unused
ArztberichtHeroCard. Remove the clinician share-link ("sharing") page,
its nav entry, slug, and section mapping; the backing model, routes, and
public clinician view stay in place for a later dedicated removal.
This commit is contained in:
1 parent
a56e6fe610
commit
eda79794d9
12 files changed
+15
-975
No files matched your search
@@ -11,7 +11,6 @@ import { ExportSection } from "@/components/settings/export-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
import { SharingSection } from "@/components/settings/sharing-section";
|
||||
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
|
||||
// separate sections again. `ThresholdsSection` renders the target-range
|
||||
// editor; `SourcesSection` (standalone mode) renders the source-priority
|
||||
@@ -53,7 +52,6 @@ const SECTION_COMPONENTS: Record<
|
||||
thresholds: ThresholdsSection,
|
||||
sources: () => <SourcesSection />,
|
||||
api: ApiSection,
|
||||
sharing: SharingSection,
|
||||
export: ExportSection,
|
||||
advanced: AdvancedSection,
|
||||
};
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Arztbericht (doctor-report) card contract.
|
||||
*
|
||||
* v1.4.37 W7a promoted this surface to the page hero; v1.12 demotes it
|
||||
* back to a small secondary card at the bottom of `/settings/export`
|
||||
* (the health-record export now owns the hero). The card stays fully
|
||||
* functional — only the framing and visual weight changed. This suite
|
||||
* pins the SSR contract:
|
||||
*
|
||||
* 1. The card renders with the expected `data-testid` slot.
|
||||
* 2. The value statement is present (Marc-Voice copy, no AI mention).
|
||||
* 3. The CTA button is present, clears the 44 px touch floor, and
|
||||
* carries an `aria-describedby` link to the value statement.
|
||||
* 4. The CTA is wired to a click handler (the prop `data-testid`
|
||||
* surface is the e2e contract).
|
||||
* 5. Both DE and EN locales resolve their copy without leaking the
|
||||
* raw i18n key.
|
||||
*
|
||||
* Project convention is SSR-only tests (no `@testing-library/react`).
|
||||
* The actual click-flow + PDF generation is exercised by the e2e
|
||||
* Playwright path and the dialog's own integration tests.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/export",
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ArztberichtHeroCard } from "../arztbericht-hero-card";
|
||||
|
||||
function renderSSR(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ArztberichtHeroCard> — SSR contract", () => {
|
||||
it("renders the card slot with eyebrow + title", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report"');
|
||||
// Title falls back to the existing doctorReport.title key — keeps
|
||||
// a single source-of-truth for the brand name.
|
||||
expect(html).toContain("Doctor Report");
|
||||
// Eyebrow surfaces the use-case framing.
|
||||
expect(html).toContain("Doctor visit");
|
||||
});
|
||||
|
||||
it("renders the value statement as a Marc-Voice one-liner", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report-value"');
|
||||
expect(html).toContain("printable PDF report");
|
||||
// No AI/Coach name-drop in the user-facing artefact (Marc-Voice).
|
||||
expect(html.toLowerCase()).not.toContain("coach");
|
||||
expect(html.toLowerCase()).not.toContain(" ai ");
|
||||
});
|
||||
|
||||
it("renders the CTA with min-h-11 touch target + aria-describedby", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
|
||||
// 44 px touch floor (WCAG 2.5.5) — the demoted CTA keeps `min-h-11`
|
||||
// on mobile and falls back to the compact `sm:min-h-9` on pointer
|
||||
// devices.
|
||||
expect(html).toContain("min-h-11");
|
||||
// Aria-describedby links the CTA to the value statement so screen
|
||||
// readers announce the framing alongside the action label.
|
||||
expect(html).toMatch(/aria-describedby="[^"]+"/);
|
||||
});
|
||||
|
||||
it("does NOT leak raw i18n keys", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
expect(html).not.toContain("settings.sections.export.hero");
|
||||
expect(html).not.toContain("settings.sections.export.cards.doctorReport");
|
||||
});
|
||||
|
||||
it("renders German copy when the DE locale is active", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />, "de");
|
||||
expect(html).toContain("Arztbericht");
|
||||
expect(html).toContain("Arzttermin");
|
||||
// Umlauts encoded as their literal UTF-8 character (not their HTML
|
||||
// entity), matching the rest of the codebase.
|
||||
expect(html).toContain("druckbarer PDF-Bericht");
|
||||
expect(html).not.toContain("settings.sections.export.hero");
|
||||
});
|
||||
|
||||
it("renders as a plain secondary card, not a hero", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
// v1.12 demoted the doctor-report from the page hero — the
|
||||
// health-record export now owns the hero treatment, so this card
|
||||
// must NOT carry the hero gradient/glow utilities.
|
||||
expect(html).not.toContain("hero-gradient");
|
||||
expect(html).not.toContain("glow-purple");
|
||||
// It renders with the standard secondary-card surface.
|
||||
expect(html).toContain("bg-card");
|
||||
});
|
||||
|
||||
it("does not paint dialog markup in the closed-state SSR pass", () => {
|
||||
// Radix Dialog renders its portal content lazily, so the closed
|
||||
// SSR pass never carries `role="dialog"`. The CTA testid IS
|
||||
// present so the e2e suite can drive the open from there. Pinning
|
||||
// this contract guards against an accidental `defaultOpen={true}`
|
||||
// regression that would briefly paint the dialog above the page on
|
||||
// every cold mount.
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
|
||||
expect(html).not.toContain('role="dialog"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ArztberichtHeroCard> — heading hierarchy", () => {
|
||||
it("uses an <h2> so it nests cleanly below the page-level <h1>", () => {
|
||||
const html = renderSSR(<ArztberichtHeroCard />);
|
||||
// Page-level h1 (settings-section-export-title) wraps the section;
|
||||
// hero owns the h2 slot. The export-section test pins the outer
|
||||
// h1; this assertion pins the hero's contribution.
|
||||
expect(html).toMatch(/<h2[^>]*id="export-hero-doctor-report-title"/);
|
||||
});
|
||||
});
|
||||
@@ -7,18 +7,16 @@
|
||||
* - Medications CSV (with optional intake-history toggle)
|
||||
* - Mood CSV
|
||||
* - Full JSON Backup
|
||||
* - Doctor Report (PDF) — small secondary card at the bottom (v1.12)
|
||||
*
|
||||
* Each card must surface a title, a 1-line description, and a
|
||||
* download/generate button. SSR-only smoke test — interaction is
|
||||
* exercised by the e2e suite.
|
||||
*
|
||||
* v1.12 — the health-record export takes the page hero; the
|
||||
* doctor-report card (`<ArztberichtHeroCard>`) is demoted to a small
|
||||
* secondary card at the bottom of the page. The four CSV/JSON tiles
|
||||
* stay under a "Weitere Export-Optionen" / "Other export options"
|
||||
* sub-heading. Each component owns its own contract test; this suite
|
||||
* pins the page-level shape.
|
||||
* The health-record export takes the page hero. The doctor-report PDF
|
||||
* now lives under the health-record export and is no longer offered as
|
||||
* a separate card here. The four CSV/JSON tiles stay under a "Weitere
|
||||
* Export-Optionen" / "Other export options" sub-heading. Each component
|
||||
* owns its own contract test; this suite pins the page-level shape.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -68,12 +66,11 @@ describe("<ExportSection> — SSR smoke", () => {
|
||||
expect(html).toContain("glow-purple");
|
||||
});
|
||||
|
||||
it("mounts the demoted doctor-report card", () => {
|
||||
it("no longer mounts a separate doctor-report card", () => {
|
||||
const html = render(<ExportSection />);
|
||||
// The card owns its own contract suite; here we pin that the page
|
||||
// mounts it, and that the legacy in-grid doctor-report card is
|
||||
// gone. The card no longer carries the hero gradient/glow (v1.12).
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report"');
|
||||
// The doctor-report PDF now lives under the health-record export;
|
||||
// the Export page no longer offers it as a separate surface.
|
||||
expect(html).not.toContain('data-testid="export-hero-doctor-report"');
|
||||
expect(html).not.toContain('data-testid="export-card-doctor-report"');
|
||||
});
|
||||
|
||||
@@ -107,19 +104,16 @@ describe("<ExportSection> — SSR smoke", () => {
|
||||
expect(html).toContain('data-testid="export-medications-include-intake"');
|
||||
});
|
||||
|
||||
it("renders a download/generate button per secondary card + the hero CTA", () => {
|
||||
it("renders a download/generate button per secondary card", () => {
|
||||
const html = render(<ExportSection />);
|
||||
// Four secondary cards each carry an `export-action-*` testid; the
|
||||
// hero owns the `export-hero-doctor-report-action` slot.
|
||||
// Four secondary cards each carry an `export-action-*` testid.
|
||||
const buttonMatches = html.match(/data-testid="export-action-/g);
|
||||
expect(buttonMatches?.length ?? 0).toBe(4);
|
||||
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
|
||||
});
|
||||
|
||||
it("German locale renders the DE heading + hero copy", () => {
|
||||
const html = render(<ExportSection />, "de");
|
||||
expect(html).toContain("Export");
|
||||
expect(html).toContain("Arztbericht");
|
||||
expect(html).toContain("Weitere Export-Optionen");
|
||||
expect(html).not.toContain("settings.sections.export.");
|
||||
});
|
||||
|
||||
@@ -48,8 +48,6 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// IW-D merged it into `thresholds`.
|
||||
// v1.8.7.1 — `sources` (Sources) is its own slug again, sitting
|
||||
// between `thresholds` (Targets) and `ai`.
|
||||
// v1.11.0 — `sharing` (clinician share links) added between `api` and
|
||||
// `export`. Section count: 12.
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"integrations",
|
||||
@@ -59,7 +57,6 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
"sources",
|
||||
"ai",
|
||||
"api",
|
||||
"sharing",
|
||||
"export",
|
||||
"advanced",
|
||||
"about",
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* v1.11.0 — Settings → Sharing (Epic C, C7).
|
||||
*
|
||||
* The owner share-link surface. The security-load-bearing guarantees the
|
||||
* test pins:
|
||||
* 1. The list never carries a raw `hls_` token — the server stores only the
|
||||
* hash, so the list response shape has no token field and the rendered
|
||||
* markup must not surface one.
|
||||
* 2. The one-time token reveal card (`share-token-reveal`) is absent until a
|
||||
* create succeeds — on first paint (no token in state) it must not render.
|
||||
* 3. Active links render with a revoke control; revoked/expired links fold
|
||||
* into the inactive list.
|
||||
* 4. The expiry input is bounded at the server cap (90 days).
|
||||
*
|
||||
* SSR-static render only (no jsdom), matching the sibling settings-section
|
||||
* tests. The TanStack hooks are mocked so the query returns a fixed link list.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const FIXTURE_LINKS = [
|
||||
{
|
||||
id: "link-active",
|
||||
label: "Cardiology referral",
|
||||
rangeStart: "2026-01-01T00:00:00.000Z",
|
||||
rangeEnd: null,
|
||||
resourceTypes: ["Patient", "Observation"],
|
||||
allowFhirApi: true,
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-06-01T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
lastAccessAt: "2026-06-02T10:00:00.000Z",
|
||||
accessCount: 3,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: "link-revoked",
|
||||
label: "Old GP link",
|
||||
rangeStart: "2026-01-01T00:00:00.000Z",
|
||||
rangeEnd: null,
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
revokedAt: "2026-05-15T00:00:00.000Z",
|
||||
lastAccessAt: null,
|
||||
accessCount: 0,
|
||||
active: false,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: FIXTURE_LINKS, isLoading: false }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SharingSection } from "../sharing-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<SharingSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SharingSection> — owner share-link surface (C7)", () => {
|
||||
it("renders the create form with a 90-day-capped expiry input", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("New share link");
|
||||
// The expiry field carries the server's max-days cap.
|
||||
expect(html).toContain('max="90"');
|
||||
expect(html).toContain("Create link");
|
||||
});
|
||||
|
||||
it("does not reveal a token before a create succeeds", () => {
|
||||
const html = render();
|
||||
// The one-time reveal card only mounts once `newToken` state is set.
|
||||
expect(html).not.toContain('data-testid="share-token-reveal"');
|
||||
});
|
||||
|
||||
it("lists the active link with a revoke control and never a raw token", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="share-active-list"');
|
||||
expect(html).toContain("Cardiology referral");
|
||||
expect(html).toContain("Revoke");
|
||||
// The list response shape carries no token; the markup must not surface
|
||||
// an `hls_` string anywhere.
|
||||
expect(html).not.toContain("hls_");
|
||||
// The FHIR badge surfaces for the FHIR-enabled link.
|
||||
expect(html).toContain("FHIR");
|
||||
});
|
||||
|
||||
it("folds revoked links into the inactive list (not the active list)", () => {
|
||||
const html = render();
|
||||
// The active list must NOT contain the revoked link's label.
|
||||
const activeListStart = html.indexOf('data-testid="share-active-list"');
|
||||
const activeListEnd = html.indexOf('data-testid="share-inactive-list"');
|
||||
// Inactive list is collapsed by default, so the label only appears once
|
||||
// the disclosure is open — but the active list must never carry it.
|
||||
const activeSlice =
|
||||
activeListEnd > activeListStart
|
||||
? html.slice(activeListStart, activeListEnd)
|
||||
: html.slice(activeListStart);
|
||||
expect(activeSlice).not.toContain("Old GP link");
|
||||
// The inactive-count disclosure surfaces.
|
||||
expect(html).toContain("Revoked and expired");
|
||||
});
|
||||
|
||||
it("renders localized section copy", () => {
|
||||
const html = render("de");
|
||||
expect(html).toContain("Neuer Freigabe-Link");
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings → Export Arztbericht (doctor-report) card.
|
||||
*
|
||||
* The doctor-report PDF is the artefact a user carries into a doctor's
|
||||
* appointment. v1.4.37 W7a promoted it to the page hero; v1.12 demotes
|
||||
* it back to a small secondary card at the bottom of the export page —
|
||||
* the health-record export now owns the page hero. The card is fully
|
||||
* functional: the CTA routes through the existing `<DoctorReportDialog>`
|
||||
* + `/api/doctor-report` flow exactly as before. Only the framing and
|
||||
* visual weight changed.
|
||||
*
|
||||
* Layout (compact card, no hero gradient):
|
||||
*
|
||||
* ┌─ card ────────────────────────────────────────────┐
|
||||
* │ [icon] H2 title eyebrow │
|
||||
* │ one-line value statement │
|
||||
* │ [Configure & generate] PDF · printable │
|
||||
* └───────────────────────────────────────────────────┘
|
||||
*
|
||||
* a11y:
|
||||
* - The CTA button clears the 44 px touch floor (`min-h-11`).
|
||||
* - `focus-visible:ring` ships via the default `<Button>` variant.
|
||||
* - The CTA is `aria-describedby={valueStatementId}` so a screen
|
||||
* reader announces the value statement alongside the action.
|
||||
* - The card's `<h2>` slots beneath the page-level `<h1>`.
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Loader2, Stethoscope } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DoctorReportDialog,
|
||||
type DoctorReportSubmitPayload,
|
||||
} from "@/components/doctor-report/doctor-report-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function ArztberichtHeroCard() {
|
||||
const { t, locale } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [defaultPracticeName, setDefaultPracticeName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const valueStatementId = useId();
|
||||
|
||||
// Pre-fill the practice name from the user profile so the dialog opens
|
||||
// ready-to-submit. Best-effort: if the request fails, the dialog still
|
||||
// works without a pre-fill — mirrors the legacy `<DoctorReportCard>`.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/auth/me", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((json) => {
|
||||
if (cancelled || !json) return;
|
||||
const name = json?.data?.lastReportPracticeName;
|
||||
if (typeof name === "string") setDefaultPracticeName(name);
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore — pre-fill is a UX nicety
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(payload: DoctorReportSubmitPayload) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/doctor-report", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
startDate: payload.startDate,
|
||||
endDate: payload.endDate,
|
||||
practiceName: payload.practiceName,
|
||||
sections: payload.sections,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(`${res.status}`);
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
const { generateDoctorReportPDF } =
|
||||
await import("@/lib/doctor-report-pdf");
|
||||
const doc = generateDoctorReportPDF(json.data, { t, locale });
|
||||
const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report";
|
||||
doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
if (payload.practiceName) setDefaultPracticeName(payload.practiceName);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="export-hero-doctor-report"
|
||||
aria-labelledby="export-hero-doctor-report-title"
|
||||
className="bg-card border-border rounded-xl border p-5 sm:p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Stethoscope
|
||||
className="text-primary h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2
|
||||
id="export-hero-doctor-report-title"
|
||||
className="text-base font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.export.cards.doctorReport.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
|
||||
{t("settings.sections.export.hero.eyebrow")}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
id={valueStatementId}
|
||||
data-testid="export-hero-doctor-report-value"
|
||||
className="text-muted-foreground max-w-2xl text-xs leading-relaxed"
|
||||
>
|
||||
{t("settings.sections.export.hero.valueStatement")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
data-testid="export-hero-doctor-report-action"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// `min-h-11` (44 px) is the WCAG 2.5.5 touch-target floor;
|
||||
// we keep it on mobile and fall back to the compact
|
||||
// `sm:min-h-9` on pointer devices so a future className
|
||||
// override can't re-lift the mobile minimum below the
|
||||
// touch-target contract.
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={busy}
|
||||
aria-describedby={valueStatementId}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sections.export.hero.cta")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.hero.formatHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DoctorReportDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
defaultPracticeName={defaultPracticeName}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -16,13 +16,11 @@
|
||||
* 3. Medications CSV — optional intake-history toggle
|
||||
* 4. Mood CSV — optional `since`/`until`
|
||||
* 5. Full JSON Backup — single-file user-scoped dump
|
||||
* 6. Doctor Report (PDF) — small secondary card at the bottom
|
||||
* (`<ArztberichtHeroCard>`)
|
||||
*
|
||||
* v1.12 — the health-record export is the headline "data out" path; it
|
||||
* takes the page hero. The doctor-report PDF is demoted to a small
|
||||
* secondary card at the bottom of the page — fully functional, just no
|
||||
* longer the flagship surface it was in v1.4.37 W7a.
|
||||
* takes the page hero. The doctor-report PDF lives under the
|
||||
* health-record export now and is no longer offered as a separate card
|
||||
* here (it used to be a small secondary card at the bottom).
|
||||
*
|
||||
* Mobile-first: cards stack on `<md`, two-column grid on `>=md`.
|
||||
*/
|
||||
@@ -42,7 +40,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { DateInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ArztberichtHeroCard } from "@/components/settings/arztbericht-hero-card";
|
||||
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -114,11 +111,6 @@ export function ExportSection() {
|
||||
<FullBackupCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* v1.12 — doctor-report PDF, demoted from the former page hero to a
|
||||
small secondary card at the bottom. Fully functional — the same
|
||||
`<DoctorReportDialog>` + `/api/doctor-report` flow runs from here. */}
|
||||
<ArztberichtHeroCard />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ export function HealthRecordExportPanel() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
Share2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
User,
|
||||
@@ -40,7 +39,6 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
sources: Layers,
|
||||
ai: Sparkles,
|
||||
api: KeyRound,
|
||||
sharing: Share2,
|
||||
export: Download,
|
||||
advanced: Settings2,
|
||||
about: Info,
|
||||
|
||||
@@ -22,10 +22,6 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
"sources",
|
||||
"ai",
|
||||
"api",
|
||||
// v1.11.0 — `sharing` owns clinician share links: a time-boxed, scope-
|
||||
// frozen read-only view of the owner's record, optionally a scoped FHIR
|
||||
// face. Sits next to `api` (the other "hand a credential out" surface).
|
||||
"sharing",
|
||||
"export",
|
||||
"advanced",
|
||||
"about",
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
Share2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
User,
|
||||
@@ -110,11 +109,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
},
|
||||
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
|
||||
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
|
||||
{
|
||||
slug: "sharing",
|
||||
titleKey: "settings.sections.sharing.title",
|
||||
icon: Share2,
|
||||
},
|
||||
{
|
||||
slug: "export",
|
||||
titleKey: "settings.sections.export.title",
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.11.0 — Settings → Sharing (Epic C, C7).
|
||||
*
|
||||
* The OWNER surface for clinician share links. A share link is a time-boxed,
|
||||
* scope-frozen, read-only view of the owner's own health record at
|
||||
* `/c/<token>`, optionally exposing a scoped read-only FHIR face. The raw
|
||||
* `hls_` token is shown EXACTLY ONCE on create — the list never carries it
|
||||
* (the server only stores its hash), so the copy-on-create card is the single
|
||||
* chance to capture it.
|
||||
*
|
||||
* Client island: the create form, the active/revoked lists, and the revoke
|
||||
* action all need state and mutate via the C4 lifecycle API
|
||||
* (`/api/share-links`). Reads unwrap `(await res.json()).data`; the query key
|
||||
* comes from the centralised factory. No markdown anywhere — every value
|
||||
* renders as escaped React text.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, Loader2, Share2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/** The FHIR resource types a share link may serve — mirrors C4's enum. */
|
||||
const RESOURCE_TYPES = [
|
||||
"Patient",
|
||||
"Observation",
|
||||
"MedicationStatement",
|
||||
"MedicationAdministration",
|
||||
] as const;
|
||||
type ResourceType = (typeof RESOURCE_TYPES)[number];
|
||||
|
||||
/** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */
|
||||
const MAX_DAYS = 90;
|
||||
const DEFAULT_DAYS = 30;
|
||||
|
||||
/** Owner-facing shape returned by `GET /api/share-links` (never the token). */
|
||||
interface ShareLinkSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
rangeStart: string;
|
||||
rangeEnd: string | null;
|
||||
resourceTypes: string[];
|
||||
allowFhirApi: boolean;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
revokedAt: string | null;
|
||||
lastAccessAt: string | null;
|
||||
accessCount: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function SharingSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-sharing-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-sharing-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.sharing.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.sharing.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ShareLinksCard />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function isoDaysFromNow(days: number): string {
|
||||
return new Date(Date.now() + days * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
function ShareLinksCard() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [label, setLabel] = useState("");
|
||||
const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS);
|
||||
const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS);
|
||||
const [allowFhirApi, setAllowFhirApi] = useState(false);
|
||||
const [resourceTypes, setResourceTypes] = useState<ResourceType[]>([
|
||||
"Patient",
|
||||
"Observation",
|
||||
]);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [showRevoked, setShowRevoked] = useState(false);
|
||||
|
||||
const { data: links } = useQuery({
|
||||
queryKey: queryKeys.shareLinks(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/share-links");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data.shareLinks as ShareLinkSummary[];
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = label.trim();
|
||||
// Surface the same expiry-bound the server enforces before the round
|
||||
// trip, so the validation feedback is immediate.
|
||||
if (expiryDays < 1 || expiryDays > MAX_DAYS) {
|
||||
throw new Error("EXPIRY_RANGE");
|
||||
}
|
||||
const res = await fetch("/api/share-links", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
label: trimmed,
|
||||
rangeStart: isoDaysFromNow(-rangeDays),
|
||||
rangeEnd: null,
|
||||
resourceTypes,
|
||||
allowFhirApi,
|
||||
expiresAt: isoDaysFromNow(expiryDays),
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || "FAILED");
|
||||
}
|
||||
return json.data as ShareLinkSummary & { token: string };
|
||||
},
|
||||
onSuccess: (created) => {
|
||||
setNewToken(created.token);
|
||||
setCopied(false);
|
||||
setLabel("");
|
||||
setFormError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setNewToken(null);
|
||||
setFormError(
|
||||
err.message === "EXPIRY_RANGE"
|
||||
? t("settings.sharing.expiryInvalid", { max: MAX_DAYS })
|
||||
: err.message === "FAILED" || err.message === ""
|
||||
? t("common.error")
|
||||
: err.message,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/share-links/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
|
||||
},
|
||||
});
|
||||
|
||||
function toggleResourceType(type: ResourceType) {
|
||||
setResourceTypes((prev) =>
|
||||
prev.includes(type)
|
||||
? prev.filter((r) => r !== type)
|
||||
: [...prev, type],
|
||||
);
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
if (!newToken) return;
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
await navigator.clipboard.writeText(`${origin}/c/${newToken}`);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
// Clipboard can be unavailable (insecure context); the token stays
|
||||
// visible in the card so the owner can copy it by hand.
|
||||
}
|
||||
}
|
||||
|
||||
const activeLinks = useMemo(
|
||||
() => (links ?? []).filter((l) => l.active),
|
||||
[links],
|
||||
);
|
||||
const inactiveLinks = useMemo(
|
||||
() => (links ?? []).filter((l) => !l.active),
|
||||
[links],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Share2}
|
||||
title={t("settings.sharing.createTitle")}
|
||||
description={t("settings.sharing.createDescription")}
|
||||
/>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-label">{t("settings.sharing.label")}</Label>
|
||||
<Input
|
||||
id="share-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("settings.sharing.labelPlaceholder")}
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-range">{t("settings.sharing.range")}</Label>
|
||||
<Input
|
||||
id="share-range"
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={rangeDays}
|
||||
onChange={(e) =>
|
||||
setRangeDays(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.rangeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-expiry">
|
||||
{t("settings.sharing.expiry")}
|
||||
</Label>
|
||||
<Input
|
||||
id="share-expiry"
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_DAYS}
|
||||
value={expiryDays}
|
||||
onChange={(e) =>
|
||||
setExpiryDays(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS}
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.expiryHint", { max: MAX_DAYS })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5 pr-3">
|
||||
<Label htmlFor="share-fhir" className="text-sm font-medium">
|
||||
{t("settings.sharing.fhirApi")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.fhirApiHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-fhir"
|
||||
checked={allowFhirApi}
|
||||
onCheckedChange={setAllowFhirApi}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{allowFhirApi && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{t("settings.sharing.resourceTypes")}
|
||||
</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{RESOURCE_TYPES.map((type) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
htmlFor={`share-rt-${type}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`share-rt-${type}`}
|
||||
checked={resourceTypes.includes(type)}
|
||||
onCheckedChange={() => toggleResourceType(type)}
|
||||
/>
|
||||
<span className="font-mono text-xs">{type}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !label.trim()}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sharing.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{newToken && (
|
||||
<div
|
||||
className="bg-success/10 space-y-2 rounded-lg p-3 text-sm"
|
||||
data-testid="share-token-reveal"
|
||||
>
|
||||
<p className="text-success font-medium">
|
||||
{t("settings.sharing.tokenCreated")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.tokenOnce")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
|
||||
{`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToken}
|
||||
aria-label={t("settings.sharing.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied && (
|
||||
<p className="text-success text-[11px]">
|
||||
{t("settings.sharing.copied")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.sharing.activeTitle")}
|
||||
</h3>
|
||||
{activeLinks.length === 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm"
|
||||
data-testid="share-active-empty"
|
||||
>
|
||||
{t("settings.sharing.noActive")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="share-active-list">
|
||||
{activeLinks.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-words">
|
||||
{link.label}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge className="bg-success/15 text-success text-[10px]">
|
||||
{t("settings.sharing.statusActive")}
|
||||
</Badge>
|
||||
{link.allowFhirApi && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
FHIR
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.created")}:
|
||||
</span>{" "}
|
||||
{formatDate(link.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.expires")}:
|
||||
</span>{" "}
|
||||
{formatDateTime(link.expiresAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.accessCount")}:
|
||||
</span>{" "}
|
||||
{link.accessCount}
|
||||
{link.lastAccessAt
|
||||
? ` · ${formatDateTime(link.lastAccessAt)}`
|
||||
: ""}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
{t("settings.sharing.revoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.sharing.revoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.sharing.revokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => revokeMutation.mutate(link.id)}
|
||||
>
|
||||
{t("settings.sharing.revoke")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inactiveLinks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRevoked((prev) => !prev)}
|
||||
className="text-foreground hover:text-primary text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("settings.sharing.inactiveTitle", {
|
||||
count: inactiveLinks.length,
|
||||
})}
|
||||
</button>
|
||||
{showRevoked && (
|
||||
<ul className="space-y-2" data-testid="share-inactive-list">
|
||||
{inactiveLinks.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-words">
|
||||
{link.label}
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{link.revokedAt
|
||||
? t("settings.sharing.statusRevoked")
|
||||
: t("settings.sharing.statusExpired")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.accessCount")}:
|
||||
</span>{" "}
|
||||
{link.accessCount}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user