mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Tidy the token, connector and share-link settings cards
The API tokens card names every kind of token it lists and reads in sentence case. Each ingest card and the connector card fold the shown-once note into the paragraph about what the token can do, and their name fields suggest a fitting example instead of one phone shortcut everywhere, short enough to stay whole on a phone. The connector card drops the status badge that repeated its switch. Share links get their own card next to the one that creates them.
This commit is contained in:
1 parent
c90d28e597
commit
c816946140
10 files changed
+315
-280
No files matched your search
@@ -91,6 +91,7 @@ function MeasurementTokensCard() {
|
||||
description: t("settings.measurementsToken.description"),
|
||||
detail: t("settings.measurementsToken.detail"),
|
||||
scopeNote: t("settings.measurementsToken.scopeNote"),
|
||||
namePlaceholder: t("settings.measurementsToken.namePlaceholder"),
|
||||
createFailed: t("settings.measurementsToken.createFailed"),
|
||||
}}
|
||||
/>
|
||||
@@ -114,6 +115,7 @@ function WorkoutTokensCard() {
|
||||
description: t("settings.workoutsToken.description"),
|
||||
detail: t("settings.workoutsToken.detail"),
|
||||
scopeNote: t("settings.workoutsToken.scopeNote"),
|
||||
namePlaceholder: t("settings.workoutsToken.namePlaceholder"),
|
||||
createFailed: t("settings.workoutsToken.createFailed"),
|
||||
}}
|
||||
/>
|
||||
@@ -137,6 +139,7 @@ function DocumentTokensCard() {
|
||||
description: t("settings.documentsToken.description"),
|
||||
detail: t("settings.documentsToken.detail"),
|
||||
scopeNote: t("settings.documentsToken.scopeNote"),
|
||||
namePlaceholder: t("settings.documentsToken.namePlaceholder"),
|
||||
createFailed: t("settings.documentsToken.createFailed"),
|
||||
}}
|
||||
/>
|
||||
@@ -161,6 +164,7 @@ function IngestTokenCard({
|
||||
description: string;
|
||||
detail: string;
|
||||
scopeNote: string;
|
||||
namePlaceholder: string;
|
||||
createFailed: string;
|
||||
};
|
||||
}) {
|
||||
@@ -234,16 +238,18 @@ function IngestTokenCard({
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm leading-relaxed">{copy.detail}</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{copy.scopeNote}
|
||||
{/* "Shown once" and what the token can do are one statement about
|
||||
the credential, so they read as one paragraph. */}
|
||||
<p className="text-sm leading-relaxed">
|
||||
{copy.detail} {copy.scopeNote}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("settings.tokenNamePlaceholder")}
|
||||
placeholder={copy.namePlaceholder}
|
||||
aria-label={t("settings.tokenNameLabel")}
|
||||
maxLength={100}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
@@ -232,15 +232,6 @@ function McpEnableCard() {
|
||||
icon={Plug}
|
||||
title={t("settings.mcp.enableTitle")}
|
||||
description={t("settings.mcp.enableDescription")}
|
||||
status={
|
||||
enabled ? (
|
||||
<Badge className="bg-success/15 text-success">
|
||||
{t("settings.mcp.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("settings.mcp.disabled")}</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -371,10 +362,12 @@ function McpTokensCard() {
|
||||
title={t("settings.mcp.tokensTitle")}
|
||||
description={t("settings.mcp.tokensDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.mcp.tokensDetail")}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm leading-relaxed">{t("settings.mcp.scopeNote")}</p>
|
||||
{/* "Shown once" is part of the same statement as the scope note, so
|
||||
it reads inside that paragraph rather than as a line of its own. */}
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t("settings.mcp.tokensDetail")} {t("settings.mcp.scopeNote")}
|
||||
</p>
|
||||
|
||||
<div className="border-border bg-muted/30 space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@@ -396,7 +389,8 @@ function McpTokensCard() {
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("settings.tokenNamePlaceholder")}
|
||||
placeholder={t("settings.mcp.tokenNamePlaceholder")}
|
||||
aria-label={t("settings.tokenNameLabel")}
|
||||
maxLength={100}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FileText, KeyRound, Link2, Trash2 } from "lucide-react";
|
||||
import { FileText, KeyRound, Link2, List, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -138,213 +138,227 @@ function ShareLinksCard() {
|
||||
[links],
|
||||
);
|
||||
|
||||
// Two cards: making a link and the links already out are separate jobs, and
|
||||
// a growing list under the form pushed the create action out of reach.
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Link2}
|
||||
title={t("settings.sharing.createTitle")}
|
||||
description={t("settings.sharing.createDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.sharing.createDetail")}</p>
|
||||
<>
|
||||
<SettingsCard id="share-link-create" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={Link2}
|
||||
title={t("settings.sharing.createTitle")}
|
||||
description={t("settings.sharing.createDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.sharing.createDetail")}</p>
|
||||
|
||||
<ShareLinkCreateForm
|
||||
key={prefill?.key ?? "new"}
|
||||
initialLabel={prefill?.label}
|
||||
initialRangeDays={prefill?.rangeDays}
|
||||
initialExpiryDays={prefill?.expiryDays}
|
||||
/>
|
||||
<ShareLinkCreateForm
|
||||
key={prefill?.key ?? "new"}
|
||||
initialLabel={prefill?.label}
|
||||
initialRangeDays={prefill?.rangeDays}
|
||||
initialExpiryDays={prefill?.expiryDays}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{isError ? (
|
||||
// A read failure must not read as "no active links" on a clinical
|
||||
// sharing surface — that is the §6 fall-through this card must avoid.
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
) : isLoading ? (
|
||||
<div className="space-y-2" data-testid="share-links-loading">
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.sharing.activeTitle")}
|
||||
</h3>
|
||||
{activeLinks.length === 0 ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
data-testid="share-active-empty"
|
||||
title={t("settings.sharing.noActive")}
|
||||
/>
|
||||
) : (
|
||||
<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-xs">
|
||||
{t("settings.sharing.statusActive")}
|
||||
</Badge>
|
||||
{link.protected && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 text-xs"
|
||||
data-testid="share-protected-badge"
|
||||
>
|
||||
<KeyRound className="h-2.5 w-2.5" />
|
||||
{t("settings.sharing.protected")}
|
||||
</Badge>
|
||||
)}
|
||||
{link.documentCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 text-xs"
|
||||
data-testid="share-doc-count-badge"
|
||||
aria-label={t("settings.sharing.documentCount", {
|
||||
count: link.documentCount,
|
||||
})}
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
{link.documentCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.created")}:
|
||||
</span>{" "}
|
||||
{formatDate(link.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.expires")}:
|
||||
</span>{" "}
|
||||
{formatDateTime(link.expiresAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.accessCount")}:
|
||||
</span>{" "}
|
||||
{link.accessCount}
|
||||
{link.lastAccessAt
|
||||
? ` · ${formatDateTime(link.lastAccessAt)}`
|
||||
: ""}
|
||||
</p>
|
||||
{/* §12 — a right-aligned row action, not a full-width
|
||||
destructive strip stacked once per link (which reads as
|
||||
an alarm wall on a list of shares). */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
|
||||
>
|
||||
<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
|
||||
onClick={() => revokeMutation.mutate(link.id)}
|
||||
>
|
||||
{t("settings.sharing.revoke")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<SettingsCard data-testid="share-links-card">
|
||||
<SettingsCardHeader
|
||||
icon={List}
|
||||
title={t("settings.sharing.activeTitle")}
|
||||
description={t("settings.sharing.activeDescription")}
|
||||
/>
|
||||
{isError ? (
|
||||
// A read failure must not read as "no active links" on a clinical
|
||||
// sharing surface — that is the §6 fall-through this card must avoid.
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
) : isLoading ? (
|
||||
<div className="space-y-2" data-testid="share-links-loading">
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</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) => (
|
||||
{activeLinks.length === 0 ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
data-testid="share-active-empty"
|
||||
title={t("settings.sharing.noActive")}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="share-active-list">
|
||||
{activeLinks.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
|
||||
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>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{link.needsReselection
|
||||
? t("settings.sharing.statusNeedsReselect")
|
||||
: link.revokedAt
|
||||
? t("settings.sharing.statusRevoked")
|
||||
: t("settings.sharing.statusExpired")}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* A link whose frozen scope predates the selection model.
|
||||
It served a scope nobody chose, so it was closed; the
|
||||
owner re-mints above with the label, window and expiry
|
||||
still in front of them. */}
|
||||
{link.needsReselection ? (
|
||||
<div
|
||||
className="space-y-1.5"
|
||||
data-testid={`share-needs-reselect-${link.id}`}
|
||||
>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sharing.reselectReason")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid={`share-reselect-${link.id}`}
|
||||
onClick={() => setPrefill(prefillFrom(link))}
|
||||
>
|
||||
{t("settings.sharing.reselectAction")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge className="bg-success/15 text-success text-xs">
|
||||
{t("settings.sharing.statusActive")}
|
||||
</Badge>
|
||||
{link.protected && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 text-xs"
|
||||
data-testid="share-protected-badge"
|
||||
>
|
||||
<KeyRound className="h-2.5 w-2.5" />
|
||||
{t("settings.sharing.protected")}
|
||||
</Badge>
|
||||
)}
|
||||
{link.documentCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 text-xs"
|
||||
data-testid="share-doc-count-badge"
|
||||
aria-label={t("settings.sharing.documentCount", {
|
||||
count: link.documentCount,
|
||||
})}
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
{link.documentCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.created")}:
|
||||
</span>{" "}
|
||||
{formatDate(link.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.expires")}:
|
||||
</span>{" "}
|
||||
{formatDateTime(link.expiresAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.accessCount")}:
|
||||
</span>{" "}
|
||||
{link.accessCount}
|
||||
{link.lastAccessAt
|
||||
? ` · ${formatDateTime(link.lastAccessAt)}`
|
||||
: ""}
|
||||
</p>
|
||||
{/* §12 — a right-aligned row action, not a full-width
|
||||
destructive strip stacked once per link (which reads as
|
||||
an alarm wall on a list of shares). */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
|
||||
>
|
||||
<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
|
||||
onClick={() => revokeMutation.mutate(link.id)}
|
||||
>
|
||||
{t("settings.sharing.revoke")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{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-xs">
|
||||
{link.needsReselection
|
||||
? t("settings.sharing.statusNeedsReselect")
|
||||
: link.revokedAt
|
||||
? t("settings.sharing.statusRevoked")
|
||||
: t("settings.sharing.statusExpired")}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* A link whose frozen scope predates the selection model.
|
||||
It served a scope nobody chose, so it was closed; the
|
||||
owner re-mints above with the label, window and expiry
|
||||
still in front of them. */}
|
||||
{link.needsReselection ? (
|
||||
<div
|
||||
className="space-y-1.5"
|
||||
data-testid={`share-needs-reselect-${link.id}`}
|
||||
>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sharing.reselectReason")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid={`share-reselect-${link.id}`}
|
||||
onClick={() => {
|
||||
setPrefill(prefillFrom(link));
|
||||
// The form it fills sits in the card above.
|
||||
document
|
||||
.getElementById("share-link-create")
|
||||
?.scrollIntoView({ block: "start" });
|
||||
}}
|
||||
>
|
||||
{t("settings.sharing.reselectAction")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.accessCount")}:
|
||||
</span>{" "}
|
||||
{link.accessCount}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user