Live Share-behörighetsnivåer: kund/partner/intern med återkallelse
Modulspecifikationens sista del implementerad:
- Ny tabell delningar: återkallbara länkar med behörighetsnivå
(kund/partner/intern), organisationsknutna via ärendet.
- Nivåstyrd filtrering på serversidan i GET /api/delad/{kod}:
kund utesluter kategoribyten, hypoteser och AI-dialog; partner
utesluter kategoribyten och AI-dialog (hypoteser visas, märkta ej
verifierade); intern visar allt. Ärendets ursprungliga delningskod
fungerar bakåtkompatibelt som kundnivå. Svaret bär nivån.
- Nya endpoints: skapa/lista delningar per ärende och återkalla per
kod — alltid organisationskontrollerat; en återkallad länk ger 404.
- Klienten: delningshanterare i rapportfliken (självhostat läge) med
skapa per nivå, kopiera och återkalla — varje åtgärd loggas i
ärendet; publika delningssidan visar nivåanpassad notis och renderar
serverfiltrerade händelser utan att dölja partner-/interninnehåll.
- OpenAPI-specen utökad (Delning, DelningsNiva, tre nya operationer)
och maskinvaliderad.
Verifierat: integrationstestets 25 kontroller gröna mot Postgres 16 —
inkl. att kundkoden filtrerar hypoteser, partnernivån visar dem men
döljer kategoribyten, internnivån visar allt, org B inte kan skapa
delning av org A:s ärende och att återkallad länk ger 404. 29
vitest-tester gröna, produktionsbygge ok, Playwright-röktest grönt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -11,15 +11,28 @@ import { metodikForArende } from "./store";
|
||||
import { tidDatum, tidKlockslag } from "./format";
|
||||
import { FelsokningSkal, Panel } from "./ui";
|
||||
|
||||
export function DelatArendeVy({ arende, nu, notis }: { arende: Arende; nu: string; notis: string }) {
|
||||
export function DelatArendeVy({
|
||||
arende,
|
||||
nu,
|
||||
notis,
|
||||
redanFiltrerad = false,
|
||||
}: {
|
||||
arende: Arende;
|
||||
nu: string;
|
||||
notis: string;
|
||||
// Sant när servern redan filtrerat per behörighetsnivå (publik delning) —
|
||||
// då renderas händelserna som de kom, inklusive t.ex. hypoteser på
|
||||
// partner-/internnivå.
|
||||
redanFiltrerad?: boolean;
|
||||
}) {
|
||||
const metodik = metodikForArende(arende);
|
||||
const b = brief(arende, metodik, nu);
|
||||
const avslutat = arAvslutat(arende);
|
||||
const bilder = foton(arende);
|
||||
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
|
||||
const kundposter = arende.handelser.filter(
|
||||
(p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ),
|
||||
);
|
||||
const kundposter = redanFiltrerad
|
||||
? arende.handelser
|
||||
: arende.handelser.filter((p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ));
|
||||
const pagaende = b.rekommenderatNastaSteg[0];
|
||||
|
||||
return (
|
||||
|
||||
@@ -98,6 +98,36 @@ export async function skapaAnvandare(
|
||||
}
|
||||
}
|
||||
|
||||
// Live Share-delningar: återkallbara länkar med behörighetsnivå.
|
||||
export type DelningsNiva = "kund" | "partner" | "intern";
|
||||
|
||||
export interface Delning {
|
||||
kod: string;
|
||||
niva: DelningsNiva;
|
||||
skapad: string;
|
||||
aterkallad: string | null;
|
||||
}
|
||||
|
||||
export async function hamtaDelningar(arendeId: string): Promise<Delning[]> {
|
||||
const res = await plattformFetch(`/api/arenden/${arendeId}/delningar`);
|
||||
if (!res.ok) throw new Error(`Fel ${res.status}`);
|
||||
return ((await res.json()) as { delningar: Delning[] }).delningar;
|
||||
}
|
||||
|
||||
export async function skapaDelning(arendeId: string, niva: DelningsNiva): Promise<string> {
|
||||
const res = await plattformFetch(`/api/arenden/${arendeId}/delningar`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ niva }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Fel ${res.status}`);
|
||||
return ((await res.json()) as { kod: string }).kod;
|
||||
}
|
||||
|
||||
export async function aterkallaDelning(kod: string): Promise<void> {
|
||||
const res = await plattformFetch(`/api/delningar/${kod}/aterkalla`, { method: "POST" });
|
||||
if (!res.ok) throw new Error(`Fel ${res.status}`);
|
||||
}
|
||||
|
||||
// Organisationsöversikt (arbetsledare/admin; servern verifierar).
|
||||
export interface OversiktsRad {
|
||||
id: string;
|
||||
|
||||
@@ -18,6 +18,14 @@ import {
|
||||
} from "@/felsokning/projektioner";
|
||||
import { metodikForArende, useFelsokning } from "@/felsokning/store";
|
||||
import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk";
|
||||
import {
|
||||
aterkallaDelning,
|
||||
hamtaDelningar,
|
||||
plattformAktiv,
|
||||
skapaDelning,
|
||||
type Delning,
|
||||
type DelningsNiva,
|
||||
} from "@/felsokning/plattform";
|
||||
import { AI_RADTYP_LABEL, fragaAi, granskaUnderlag, sammanfattaOverlamning } from "@/felsokning/ai";
|
||||
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
|
||||
@@ -605,6 +613,104 @@ function OverlamningDialog({
|
||||
);
|
||||
}
|
||||
|
||||
const NIVA_LABEL: Record<DelningsNiva, string> = {
|
||||
kund: "Kund",
|
||||
partner: "Extern partner",
|
||||
intern: "Intern",
|
||||
};
|
||||
|
||||
// Live Share-behörighetsnivåer: skapa och återkalla delningslänkar.
|
||||
// Nivåfiltreringen sker alltid på serversidan; verkstaden kontrollerar
|
||||
// delningen och varje skapad/återkallad länk loggas i ärendet.
|
||||
function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
|
||||
const [oppen, setOppen] = useState(false);
|
||||
const [delningar, setDelningar] = useState<Delning[]>([]);
|
||||
const [fel, setFel] = useState("");
|
||||
|
||||
const uppdatera = async () => {
|
||||
try {
|
||||
setDelningar(await hamtaDelningar(arende.id));
|
||||
setFel("");
|
||||
} catch {
|
||||
setFel("Kunde inte hämta delningar — kräver inloggning och att ärendet är synkat.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!oppen) {
|
||||
return (
|
||||
<StorKnapp
|
||||
variant="sekundar"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
setOppen(true);
|
||||
uppdatera();
|
||||
}}
|
||||
>
|
||||
🔗 Delningslänkar (kund/partner/intern)
|
||||
</StorKnapp>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border-2 border-zinc-700 bg-zinc-950 p-3">
|
||||
{fel && <p className="mb-2 font-bold text-red-400">{fel}</p>}
|
||||
{delningar.map((delning) => (
|
||||
<div key={delning.kod} className="flex items-center justify-between gap-2 border-b border-zinc-800 py-2 last:border-0">
|
||||
<span className="text-lg">
|
||||
{NIVA_LABEL[delning.niva]}{" "}
|
||||
{delning.aterkallad && <span className="text-sm font-bold text-red-400">(återkallad)</span>}
|
||||
</span>
|
||||
{!delning.aterkallad && (
|
||||
<span className="flex gap-2">
|
||||
<button
|
||||
className="rounded border-2 border-zinc-600 px-3 py-1 font-bold text-zinc-200 hover:border-amber-400"
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${delning.kod}`)
|
||||
}
|
||||
>
|
||||
Kopiera
|
||||
</button>
|
||||
<button
|
||||
className="rounded border-2 border-zinc-600 px-3 py-1 font-bold text-red-400 hover:border-red-400"
|
||||
onClick={async () => {
|
||||
await aterkallaDelning(delning.kod);
|
||||
skicka({ typ: "kommentar", text: `Delningslänk (${NIVA_LABEL[delning.niva]}) återkallad.` });
|
||||
uppdatera();
|
||||
}}
|
||||
>
|
||||
Återkalla
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
{(Object.keys(NIVA_LABEL) as DelningsNiva[]).map((niva) => (
|
||||
<button
|
||||
key={niva}
|
||||
className="min-h-12 rounded-lg border-2 border-zinc-600 text-sm font-extrabold text-zinc-200 hover:border-amber-400"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await skapaDelning(arende.id, niva);
|
||||
skicka({ typ: "kommentar", text: `Delningslänk skapad (${NIVA_LABEL[niva]}).` });
|
||||
uppdatera();
|
||||
} catch {
|
||||
setFel("Kunde inte skapa delning — kräver inloggning och att ärendet är synkat.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ {NIVA_LABEL[niva]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
Kund: det kunddelbara · Extern partner: även hypoteser (märkta ej verifierade) · Intern: full insyn.
|
||||
Filtreringen sker på servern.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoggFlik({ arende }: { arende: Arende }) {
|
||||
return (
|
||||
<Panel rubrik="Arbetslogg — append-only, ingenting skrivs över">
|
||||
@@ -808,17 +914,21 @@ function RapportFlik({
|
||||
<Link to={`/felsokning/dela/${arende.id}`} className="mt-2 block">
|
||||
<StorKnapp variant="sekundar">🟢 Öppna Live Share-vy</StorKnapp>
|
||||
</Link>
|
||||
{arende.delningskod && (
|
||||
<StorKnapp
|
||||
variant="sekundar"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${arende.delningskod}`);
|
||||
skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." });
|
||||
}}
|
||||
>
|
||||
🔗 Kopiera delningslänk
|
||||
</StorKnapp>
|
||||
{plattformAktiv() ? (
|
||||
<DelningsHanterare arende={arende} skicka={skicka} />
|
||||
) : (
|
||||
arende.delningskod && (
|
||||
<StorKnapp
|
||||
variant="sekundar"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${arende.delningskod}`);
|
||||
skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." });
|
||||
}}
|
||||
>
|
||||
🔗 Kopiera delningslänk
|
||||
</StorKnapp>
|
||||
)
|
||||
)}
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare).
|
||||
|
||||
@@ -12,9 +12,17 @@ import { FelsokningSkal, Panel } from "@/felsokning/ui";
|
||||
interface DelatSvar {
|
||||
arende: { id: string; nummer: number; skapad: string };
|
||||
handelser: { id: string; tidpunkt: string; anvandare: string; handelse: Handelse }[];
|
||||
niva?: "kund" | "partner" | "intern";
|
||||
}
|
||||
|
||||
async function hamtaDelat(kod: string): Promise<Arende | undefined> {
|
||||
const NIVA_NOTIS: Record<string, string> = {
|
||||
kund: "Skrivskyddad livevy från verkstaden — sidan uppdateras automatiskt när ny information registreras.",
|
||||
partner:
|
||||
"Skrivskyddad livevy (partnernivå) — inkluderar tekniska hypoteser, tydligt märkta som ej verifierade. Uppdateras automatiskt.",
|
||||
intern: "Skrivskyddad livevy (intern nivå) — full insyn i ärendet. Uppdateras automatiskt.",
|
||||
};
|
||||
|
||||
async function hamtaDelat(kod: string): Promise<{ arende: Arende; niva: string } | undefined> {
|
||||
const { plattformAktiv, PLATTFORM_URL } = await import("@/felsokning/plattform");
|
||||
let data: unknown;
|
||||
if (plattformAktiv()) {
|
||||
@@ -32,21 +40,25 @@ async function hamtaDelat(kod: string): Promise<Arende | undefined> {
|
||||
}
|
||||
const svar = data as DelatSvar;
|
||||
return {
|
||||
id: svar.arende.id,
|
||||
nummer: svar.arende.nummer,
|
||||
skapad: new Date(svar.arende.skapad).toISOString(),
|
||||
handelser: svar.handelser.map((rad) => ({
|
||||
id: rad.id,
|
||||
tidpunkt: new Date(rad.tidpunkt).toISOString(),
|
||||
anvandare: rad.anvandare,
|
||||
handelse: rad.handelse,
|
||||
})),
|
||||
niva: svar.niva ?? "kund",
|
||||
arende: {
|
||||
id: svar.arende.id,
|
||||
nummer: svar.arende.nummer,
|
||||
skapad: new Date(svar.arende.skapad).toISOString(),
|
||||
handelser: svar.handelser.map((rad) => ({
|
||||
id: rad.id,
|
||||
tidpunkt: new Date(rad.tidpunkt).toISOString(),
|
||||
anvandare: rad.anvandare,
|
||||
handelse: rad.handelse,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function PublikDelning() {
|
||||
const { kod } = useParams<{ kod: string }>();
|
||||
const [arende, setArende] = useState<Arende | undefined>();
|
||||
const [niva, setNiva] = useState<string>("kund");
|
||||
const [status, setStatus] = useState<"laddar" | "klar" | "saknas">("laddar");
|
||||
const [nu, setNu] = useState(() => new Date().toISOString());
|
||||
|
||||
@@ -57,7 +69,8 @@ export default function PublikDelning() {
|
||||
try {
|
||||
const resultat = await hamtaDelat(kod);
|
||||
if (!aktiv) return;
|
||||
setArende(resultat);
|
||||
setArende(resultat?.arende);
|
||||
if (resultat) setNiva(resultat.niva);
|
||||
setStatus(resultat ? "klar" : "saknas");
|
||||
setNu(new Date().toISOString());
|
||||
} catch {
|
||||
@@ -90,7 +103,8 @@ export default function PublikDelning() {
|
||||
<DelatArendeVy
|
||||
arende={arende}
|
||||
nu={nu}
|
||||
notis="Skrivskyddad livevy från verkstaden — sidan uppdateras automatiskt när ny information registreras."
|
||||
notis={NIVA_NOTIS[niva] ?? NIVA_NOTIS.kund}
|
||||
redanFiltrerad
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user