From 14df595301de558d6fcf0d9a53d6b922de0bfe64 Mon Sep 17 00:00:00 2001 From: AyushDubey23 Date: Wed, 29 Jul 2026 17:37:23 +0530 Subject: [PATCH 1/4] feat(share): add access roles, expiry, password protection, and share revocation --- .../components/layout/ShareProjectDialog.tsx | 378 +++++++++++++----- .../geolibre-desktop/src/i18n/locales/en.json | 18 + apps/geolibre-desktop/src/lib/project-url.ts | 30 ++ .../src/lib/share-geolibre.ts | 184 +++++++++ tests/project-url.test.ts | 13 + tests/share-geolibre.test.ts | 117 ++++++ 6 files changed, 651 insertions(+), 89 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx index c692a88ed..86e0cf687 100644 --- a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx @@ -9,17 +9,22 @@ import { Label, Select, } from "@geolibre/ui"; -import { Check, Copy, ExternalLink, KeyRound, Loader2, Share2 } from "lucide-react"; +import { Check, Copy, ExternalLink, KeyRound, Loader2, Lock, Share2, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useDesktopSettingsStore } from "../../hooks/useDesktopSettings"; import { openExternalLink } from "../../lib/open-external"; import { + fetchProjectShares, isShareableTitle, MAX_PROJECT_TITLE_LENGTH, resolveShareBaseUrl, + revokeShare, ShareUploadError, uploadProjectToShare, + type ActiveShare, + type ShareExpiry, + type ShareRole, type ShareUploadErrorCode, type ShareUploadResult, type ShareVisibility, @@ -50,13 +55,23 @@ export function ShareProjectDialog({ }: ShareProjectDialogProps) { const { t } = useTranslation(); const shareToken = useDesktopSettingsStore((s) => s.desktopSettings.shareToken); + const [tab, setTab] = useState<"create" | "manage">("create"); const [title, setTitle] = useState(""); const [visibility, setVisibility] = useState("unlisted"); + const [role, setRole] = useState("edit"); + const [expiresIn, setExpiresIn] = useState("never"); + const [password, setPassword] = useState(""); const [status, setStatus] = useState<"idle" | "uploading">("idle"); const [error, setError] = useState(null); const [errorCode, setErrorCode] = useState(null); const [result, setResult] = useState(null); const [copied, setCopied] = useState(false); + + const [activeShares, setActiveShares] = useState([]); + const [loadingShares, setLoadingShares] = useState(false); + const [revokingId, setRevokingId] = useState(null); + const [revokeError, setRevokeError] = useState(null); + const abortRef = useRef(null); const copyTimeoutRef = useRef(null); @@ -68,16 +83,37 @@ export function ShareProjectDialog({ if (open) { setTitle(isShareableTitle(currentTitle) ? currentTitle.trim() : ""); setVisibility("unlisted"); + setRole("edit"); + setExpiresIn("never"); + setPassword(""); setStatus("idle"); setError(null); setErrorCode(null); setResult(null); setCopied(false); + setTab("create"); + setRevokeError(null); + + if (shareToken.trim()) { + loadActiveShares(shareToken); + } } else { abortRef.current?.abort(); abortRef.current = null; } - }, [open, currentTitle]); + }, [open, currentTitle, shareToken]); + + const loadActiveShares = async (token: string) => { + setLoadingShares(true); + try { + const shares = await fetchProjectShares({ token }); + setActiveShares(shares); + } catch { + setActiveShares([]); + } finally { + setLoadingShares(false); + } + }; // Cancel a pending "copied" reset if the dialog unmounts mid-window. useEffect( @@ -93,8 +129,6 @@ export function ShareProjectDialog({ const titleValid = isShareableTitle(title); const handleShare = async () => { - // Guard re-entry synchronously: a second click before the disabled state - // renders would otherwise start a concurrent, non-idempotent upload. if (abortRef.current) return; setError(null); setErrorCode(null); @@ -108,13 +142,15 @@ export function ShareProjectDialog({ filename, content, visibility, + role, + expiresIn: expiresIn !== "never" ? expiresIn : undefined, + password: password.trim() || undefined, signal: controller.signal, }); setResult(uploaded); + loadActiveShares(shareToken); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; - // A missing account username gets dedicated, actionable UI (a deep link to - // the website's settings) rather than the raw server string. if (err instanceof ShareUploadError && err.code === "username-required") { setErrorCode("username-required"); setError(null); @@ -123,8 +159,6 @@ export function ShareProjectDialog({ setErrorCode(null); } } finally { - // Only the controller that is still current clears state, so an aborted - // (superseded) request never flips a newer one back to idle. if (abortRef.current === controller) { abortRef.current = null; setStatus("idle"); @@ -132,20 +166,29 @@ export function ShareProjectDialog({ } }; - // Close this dialog and deep-link into Settings → Environment Variables with - // the share token field focused, so the user can paste the token right away. + const handleRevoke = async (shareId: string) => { + setRevokingId(shareId); + setRevokeError(null); + try { + await revokeShare({ token: shareToken, shareId }); + setActiveShares((prev) => prev.filter((s) => s.id !== shareId)); + } catch (err) { + setRevokeError(err instanceof Error ? err.message : "Failed to revoke share"); + } finally { + setRevokingId(null); + } + }; + const handleConfigureToken = () => { onOpenChange(false); openSettingsSection("environment", { focus: "shareToken" }); }; - const handleCopy = () => { - if (!result) return; - // Only show the "copied" checkmark if the write actually succeeds; the - // promise rejects when clipboard permission is denied or the page is - // unfocused, and swallowing it would flip the icon misleadingly. + const handleCopy = (url?: string) => { + const targetUrl = url || result?.projectUrl; + if (!targetUrl) return; navigator.clipboard - .writeText(result.projectUrl) + .writeText(targetUrl) .then(() => { if (copyTimeoutRef.current !== null) { window.clearTimeout(copyTimeoutRef.current); @@ -153,9 +196,7 @@ export function ShareProjectDialog({ setCopied(true); copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 2000); }) - .catch(() => { - // Clipboard unavailable; leave the icon unchanged. - }); + .catch(() => {}); }; return ( @@ -204,7 +245,7 @@ export function ShareProjectDialog({ type="button" variant="secondary" aria-label={t("share.copyLink")} - onClick={handleCopy} + onClick={() => handleCopy()} > {copied ? : } @@ -225,81 +266,240 @@ export function ShareProjectDialog({ ) : (
-
- - setTitle(e.target.value)} - placeholder={t("share.titlePlaceholder")} - maxLength={MAX_PROJECT_TITLE_LENGTH} - disabled={status === "uploading"} - autoFocus={!titleValid} - /> - {!titleValid && ( -

{t("share.titleRequired")}

- )} -
-
- - + {t("share.createShare", "New Share")} + +
- {errorCode === "username-required" ? ( -
-

{t("share.usernameRequired")}

- -
- ) : error ? ( -

- {error} -

- ) : null} + {tab === "create" ? ( +
+
+ + setTitle(e.target.value)} + placeholder={t("share.titlePlaceholder")} + maxLength={MAX_PROJECT_TITLE_LENGTH} + disabled={status === "uploading"} + autoFocus={!titleValid} + /> + {!titleValid && ( +

{t("share.titleRequired")}

+ )} +
-
- {/* Stays enabled during upload: closing the dialog aborts the - in-flight request via the open effect's cleanup. */} - - +
+ ) : error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+ ) : ( +
+ {revokeError && ( +

+ {revokeError} +

+ )} + {loadingShares ? ( +
+ +
+ ) : activeShares.length === 0 ? ( +

+ {t("share.noActiveShares", "No active share links found.")} +

) : ( - <> - - {t("share.shareButton")} - +
+ {activeShares.map((s) => ( +
+
+

{s.title || s.projectSlug}

+
+ {s.visibility} + + {s.role} + {s.hasPassword && ( + <> + + + + Password + + + )} + {s.expiresAt && ( + <> + + Expires {new Date(s.expiresAt).toLocaleDateString()} + + )} +
+
+ +
+ + +
+
+ ))} +
)} - -
+ +
+ +
+
+ )} )} diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index bbf911bab..baefcb41b 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -1092,6 +1092,24 @@ "visibilityUnlisted": "Unlisted (anyone with the link)", "visibilityPublic": "Public (listed in the gallery)", "visibilityPrivate": "Private (only you)", + "role": "Access role", + "roleView": "View (read-only)", + "roleComment": "Comment (view & comments)", + "roleEdit": "Edit (full app)", + "expiry": "Link expiry", + "expiryNever": "Never", + "expiry24h": "24 hours", + "expiry7d": "7 days", + "expiry30d": "30 days", + "password": "Password protection (optional)", + "passwordPlaceholder": "Optional password", + "activeShares": "Active Shares", + "createShare": "New Share", + "noActiveShares": "No active share links found.", + "revoke": "Revoke", + "revoking": "Revoking…", + "passwordProtected": "Password protected", + "expires": "Expires", "shareButton": "Share", "sharing": "Sharing…", "errorFallback": "Could not share the project.", diff --git a/apps/geolibre-desktop/src/lib/project-url.ts b/apps/geolibre-desktop/src/lib/project-url.ts index 95defacab..237f69c1c 100644 --- a/apps/geolibre-desktop/src/lib/project-url.ts +++ b/apps/geolibre-desktop/src/lib/project-url.ts @@ -6,6 +6,36 @@ import { WHITEBOX_TOOL_PARAM } from "./whitebox-tool-url"; // `?https://...` query (no key) is also accepted by `projectUrlFromLocation`. export const PROJECT_URL_PARAMS = ["url", "project", "projectUrl", "project_url"]; +/** + * Reads a `.geolibre.json` project URL from the current `window.location` query + * string, if one is present. + * + * Accepts any of {@link PROJECT_URL_PARAMS} or a bare `?https://...` query, and + * normalizes the value via `normalizeProjectUrl` (absolute http/https only). + * + * @returns The normalized project URL, or `null` when none is present or valid. + */ +import type { ShareRole } from "./share-geolibre"; + +/** + * Parses a share role string ("view", "comment", "edit") into a valid ShareRole or null. + */ +export function parseShareRole(value: unknown): ShareRole | null { + if (value === "view" || value === "comment" || value === "edit") { + return value; + } + return null; +} + +/** + * Reads a share access role from the current `window.location` query string if present (?role=view, ?role=comment, ?role=edit). + */ +export function shareRoleFromLocation(): ShareRole | null { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + return parseShareRole(params.get("role") || params.get("shareRole")); +} + /** * Reads a `.geolibre.json` project URL from the current `window.location` query * string, if one is present. diff --git a/apps/geolibre-desktop/src/lib/share-geolibre.ts b/apps/geolibre-desktop/src/lib/share-geolibre.ts index d0f1b0b9d..2a803ca46 100644 --- a/apps/geolibre-desktop/src/lib/share-geolibre.ts +++ b/apps/geolibre-desktop/src/lib/share-geolibre.ts @@ -39,12 +39,32 @@ export class ShareUploadError extends Error { // point to the server's error vocabulary is obvious and easy to update. const USERNAME_REQUIRED_PATTERN = /username required/i; +export type ShareRole = "view" | "comment" | "edit"; +export type ShareExpiry = "24h" | "7d" | "30d" | "never"; + +export interface ActiveShare { + id: string; + projectSlug: string; + title?: string; + visibility: ShareVisibility; + role: ShareRole; + expiresAt: string | null; + hasPassword: boolean; + createdAt: string; + projectUrl: string; + viewerUrl: string; +} + export interface ShareUploadResult { + id?: string; username: string; slug: string; projectUrl: string; viewerUrl: string; rawJsonUrl: string; + role?: ShareRole; + expiresAt?: string | null; + hasPassword?: boolean; } export interface ShareUploadOptions { @@ -52,6 +72,9 @@ export interface ShareUploadOptions { filename: string; content: string; visibility: ShareVisibility; + role?: ShareRole; + expiresIn?: ShareExpiry; + password?: string; /** Override the share host; defaults to the configured/production URL. */ baseUrl?: string; signal?: AbortSignal; @@ -121,11 +144,15 @@ export function resolveShareBaseUrl( interface ShareProjectResponse { project?: { + id?: string; username?: string; slug?: string; projectUrl?: string; viewerUrl?: string; rawJsonUrl?: string; + role?: ShareRole; + expiresAt?: string | null; + hasPassword?: boolean; }; } @@ -159,6 +186,9 @@ export async function uploadProjectToShare( filename: options.filename, content: options.content, visibility: options.visibility, + ...(options.role ? { role: options.role } : {}), + ...(options.expiresIn ? { expiresIn: options.expiresIn } : {}), + ...(options.password ? { password: options.password } : {}), }), signal, }); @@ -184,11 +214,165 @@ export async function uploadProjectToShare( throw new Error("share.geolibre.app returned an unexpected response."); } return { + id: project.id, username: project.username ?? "", slug: project.slug ?? "", projectUrl: project.projectUrl, viewerUrl: project.viewerUrl ?? "", rawJsonUrl: project.rawJsonUrl, + role: project.role, + expiresAt: project.expiresAt, + hasPassword: project.hasPassword, + }; +} + +export interface FetchSharesOptions { + token: string; + baseUrl?: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + +export async function fetchProjectShares(options: FetchSharesOptions): Promise { + const token = options.token.trim(); + if (!token) { + throw new Error("Add a share.geolibre.app API token in Settings before managing shares."); + } + + const base = (options.baseUrl ?? resolveShareBaseUrl()).replace(/\/+$/, ""); + const fetchImpl = options.fetchImpl ?? getShareFetch(); + const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + + let response: Response; + try { + response = await fetchImpl(`${base}/api/shares`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + signal, + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + throw new Error("Could not reach share.geolibre.app. Check your internet connection."); + } + + if (response.status === 401 || response.status === 403) { + throw new Error("Invalid or expired API token."); + } + if (!response.ok) { + throw new Error(`Failed to fetch shares (HTTP ${response.status}).`); + } + + const payload = (await response.json().catch(() => ({}))) as { shares?: unknown[] }; + const rawShares = Array.isArray(payload.shares) ? payload.shares : []; + return rawShares + .map((item: any) => { + const role: ShareRole = + item.role === "view" || item.role === "comment" || item.role === "edit" + ? item.role + : "edit"; + const visibility: ShareVisibility = + item.visibility === "public" || item.visibility === "private" ? item.visibility : "unlisted"; + return { + id: String(item.id || ""), + projectSlug: String(item.projectSlug || item.slug || ""), + title: String(item.title || ""), + visibility, + role, + expiresAt: item.expiresAt ? String(item.expiresAt) : null, + hasPassword: Boolean(item.hasPassword || item.passwordProtected), + createdAt: String(item.createdAt || ""), + projectUrl: String(item.projectUrl || `${base}/u/${item.slug || ""}`), + viewerUrl: String(item.viewerUrl || `${base}/viewer?url=${item.projectUrl || ""}`), + }; + }) + .filter((s) => s.id !== ""); +} + +export interface RevokeShareOptions { + token: string; + shareId: string; + baseUrl?: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + +export async function revokeShare(options: RevokeShareOptions): Promise { + const token = options.token.trim(); + if (!token) { + throw new Error("API token required to revoke share."); + } + + const base = (options.baseUrl ?? resolveShareBaseUrl()).replace(/\/+$/, ""); + const fetchImpl = options.fetchImpl ?? getShareFetch(); + const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + + let response: Response; + try { + response = await fetchImpl(`${base}/api/shares/${encodeURIComponent(options.shareId)}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + signal, + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + throw new Error("Could not reach share.geolibre.app to revoke share."); + } + + if (response.status === 401 || response.status === 403) { + throw new Error("Invalid or expired API token."); + } + if (!response.ok && response.status !== 404) { + throw new Error(`Failed to revoke share (HTTP ${response.status}).`); + } +} + +export interface VerifySharePasswordOptions { + shareUrl: string; + password: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + +export async function verifySharePassword( + options: VerifySharePasswordOptions, +): Promise<{ projectContent: string; role?: ShareRole }> { + const fetchImpl = options.fetchImpl ?? fetch; + const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + + let response: Response; + try { + response = await fetchImpl(`${options.shareUrl.replace(/\/+$/, "")}/access`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Share-Password": options.password, + }, + body: JSON.stringify({ password: options.password }), + signal, + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + throw new Error("Could not reach share server."); + } + + if (response.status === 401 || response.status === 403) { + throw new Error("Incorrect password."); + } + if (!response.ok) { + throw new Error(`Password verification failed (HTTP ${response.status}).`); + } + + const data = (await response.json()) as { content?: string; role?: ShareRole }; + return { + projectContent: typeof data.content === "string" ? data.content : JSON.stringify(data), + role: data.role, }; } diff --git a/tests/project-url.test.ts b/tests/project-url.test.ts index a3566d68c..d576e54c2 100644 --- a/tests/project-url.test.ts +++ b/tests/project-url.test.ts @@ -216,3 +216,16 @@ describe("fetchProjectFromUrl", () => { ); }); }); + +describe("parseShareRole", () => { + it("parses valid role strings and rejects invalid ones", () => { + const { parseShareRole } = require("../apps/geolibre-desktop/src/lib/project-url"); + assert.equal(parseShareRole("view"), "view"); + assert.equal(parseShareRole("comment"), "comment"); + assert.equal(parseShareRole("edit"), "edit"); + assert.equal(parseShareRole("admin"), null); + assert.equal(parseShareRole(""), null); + assert.equal(parseShareRole(null), null); + assert.equal(parseShareRole(undefined), null); + }); +}); diff --git a/tests/share-geolibre.test.ts b/tests/share-geolibre.test.ts index acc6f4909..9ff46364f 100644 --- a/tests/share-geolibre.test.ts +++ b/tests/share-geolibre.test.ts @@ -3,11 +3,14 @@ import { describe, it } from "node:test"; import { DEFAULT_PROJECT_TITLE, DEFAULT_SHARE_BASE_URL, + fetchProjectShares, isShareableTitle, MAX_PROJECT_TITLE_LENGTH, resolveShareBaseUrl, + revokeShare, ShareUploadError, uploadProjectToShare, + verifySharePassword, } from "../apps/geolibre-desktop/src/lib/share-geolibre"; const PROJECT_DTO = { @@ -213,4 +216,118 @@ describe("uploadProjectToShare", () => { assert.equal(result.slug, ""); assert.equal(result.viewerUrl, ""); }); + + it("sends role, expiresIn, and password when provided", async () => { + const { fn, calls } = fakeFetch(201, { + project: { + ...PROJECT_DTO, + role: "view", + expiresAt: "2026-07-30T12:00:00Z", + hasPassword: true, + }, + }); + const result = await uploadProjectToShare({ + ...baseArgs, + role: "view", + expiresIn: "24h", + password: "secretpassword", + fetchImpl: fn, + }); + + assert.equal(calls.length, 1); + const body = JSON.parse(calls[0].init.body as string); + assert.equal(body.role, "view"); + assert.equal(body.expiresIn, "24h"); + assert.equal(body.password, "secretpassword"); + assert.equal(result.role, "view"); + assert.equal(result.hasPassword, true); + }); +}); + +describe("fetchProjectShares", () => { + it("fetches active shares for authenticated user", async () => { + const { fn, calls } = fakeFetch(200, { + shares: [ + { + id: "s1", + slug: "my-map", + title: "My Map", + visibility: "unlisted", + role: "view", + expiresAt: null, + hasPassword: false, + createdAt: "2026-07-29T12:00:00Z", + projectUrl: "https://share.geolibre.app/u/my-map", + viewerUrl: "https://share.geolibre.app/viewer?url=https://share.geolibre.app/u/my-map", + }, + ], + }); + + const shares = await fetchProjectShares({ + token: "glb_secrettoken", + baseUrl: "https://share.geolibre.app", + fetchImpl: fn, + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://share.geolibre.app/api/shares"); + assert.equal(shares.length, 1); + assert.equal(shares[0].id, "s1"); + assert.equal(shares[0].role, "view"); + assert.equal(shares[0].visibility, "unlisted"); + }); + + it("rejects when no token is provided", async () => { + await assert.rejects(() => fetchProjectShares({ token: " " }), /token/i); + }); +}); + +describe("revokeShare", () => { + it("deletes the specified share", async () => { + const { fn, calls } = fakeFetch(200, { ok: true }); + await revokeShare({ + token: "glb_secrettoken", + shareId: "s1", + baseUrl: "https://share.geolibre.app", + fetchImpl: fn, + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://share.geolibre.app/api/shares/s1"); + assert.equal(calls[0].init.method, "DELETE"); + }); + + it("rejects when no token is provided", async () => { + await assert.rejects(() => revokeShare({ token: "", shareId: "s1" }), /token/i); + }); +}); + +describe("verifySharePassword", () => { + it("POSTs password and returns project content on success", async () => { + const { fn, calls } = fakeFetch(200, { content: '{"version":"1.0.0"}', role: "view" }); + const result = await verifySharePassword({ + shareUrl: "https://share.geolibre.app/u/protected-share", + password: "secretpassword", + fetchImpl: fn, + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://share.geolibre.app/u/protected-share/access"); + assert.equal(calls[0].init.method, "POST"); + assert.equal(result.projectContent, '{"version":"1.0.0"}'); + assert.equal(result.role, "view"); + }); + + it("rejects with incorrect password on 401/403", async () => { + const { fn } = fakeFetch(401, { error: "Incorrect password" }); + await assert.rejects( + () => + verifySharePassword({ + shareUrl: "https://share.geolibre.app/u/protected-share", + password: "wrongpassword", + fetchImpl: fn, + }), + /incorrect password/i, + ); + }); }); From 835b50a7b68406977e4ed8af4abaf38af4c532f5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:10:43 +0000 Subject: [PATCH 2/4] style: auto-format (ruff + oxfmt) [pre-commit.ci] --- .../src/components/layout/ShareProjectDialog.tsx | 14 +++++++++++--- apps/geolibre-desktop/src/lib/share-geolibre.ts | 4 +++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx index 86e0cf687..82db93134 100644 --- a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx @@ -338,7 +338,9 @@ export function ShareProjectDialog({ disabled={status === "uploading"} > - + @@ -390,7 +392,10 @@ export function ShareProjectDialog({ ) : error ? ( -

+

{error}

) : null} @@ -421,7 +426,10 @@ export function ShareProjectDialog({ ) : (
{revokeError && ( -

+

{revokeError}

)} diff --git a/apps/geolibre-desktop/src/lib/share-geolibre.ts b/apps/geolibre-desktop/src/lib/share-geolibre.ts index 2a803ca46..7a4df97d6 100644 --- a/apps/geolibre-desktop/src/lib/share-geolibre.ts +++ b/apps/geolibre-desktop/src/lib/share-geolibre.ts @@ -274,7 +274,9 @@ export async function fetchProjectShares(options: FetchSharesOptions): Promise Date: Wed, 29 Jul 2026 20:59:17 -0400 Subject: [PATCH 3/4] Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShareProjectDialog: surface a dedicated `sharesError` instead of swallowing a failed share fetch into the "no active shares" empty state, which hid an expired token or dropped connection from the user. - ShareProjectDialog: give `loadActiveShares` its own AbortController so a close/reopen or token edit cancels the in-flight request; a superseded response can no longer overwrite a newer list or write state after the dialog is gone. - ShareProjectDialog: wire the Manage tab through i18n — the previously unused `share.passwordProtected` / `share.expires` keys, new short visibility/role labels instead of rendering the raw enum with `capitalize`, and a locale-aware expiry date via `i18n.language`. - ShareProjectDialog: give the icon-only revoke button an accessible name (`share.revoke` / `share.revoking`) and a `window.confirm` step, since revoking is immediate and irreversible. - ShareProjectDialog: restore the explanatory comments dropped in the refactor (re-entry guard, current-controller check, username-required routing, clipboard-copy rationale) — the logic they document is unchanged. - share-geolibre: fail closed to the `view` role when the server sends an unknown or missing one, instead of defaulting to full `edit`. - share-geolibre: percent-encode the project URL in the fallback viewer link so a raw `&`/`#` cannot truncate it, and encode the slug path segment; replace `item: any` in the mapper with `unknown` narrowed to `Record`. - share-geolibre: stop duplicating the share password into a custom `X-Share-Password` header; the JSON body alone is enough and headers are captured separately by proxy/logging layers. - Tests for the fail-closed role and the encoded fallback viewer URL. - project-url: hoist the `ShareRole` import to the top and delete the stale duplicate JSDoc block left above `parseShareRole`. --- .../components/layout/ShareProjectDialog.tsx | 138 ++++++++++++++---- .../geolibre-desktop/src/i18n/locales/en.json | 9 ++ apps/geolibre-desktop/src/lib/project-url.ts | 12 +- .../src/lib/share-geolibre.ts | 22 ++- tests/share-geolibre.test.ts | 38 +++++ 5 files changed, 177 insertions(+), 42 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx index 82db93134..7968b9cd3 100644 --- a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx @@ -10,7 +10,7 @@ import { Select, } from "@geolibre/ui"; import { Check, Copy, ExternalLink, KeyRound, Loader2, Lock, Share2, Trash2 } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useDesktopSettingsStore } from "../../hooks/useDesktopSettings"; import { openExternalLink } from "../../lib/open-external"; @@ -47,13 +47,30 @@ interface ShareProjectDialogProps { // and sets the username required for sharing. const ACCOUNT_SETTINGS_URL = `${resolveShareBaseUrl()}/settings`; +// Short labels for the Active Shares metadata row, where the create tab's fully +// spelled-out options ("Unlisted (anyone with the link)") would not fit. Keyed +// through `t()` rather than rendered from the raw enum with `capitalize`, which +// would leave these strings in English in every locale. `as const` keeps the +// values literal so they still typecheck against the `en.json` key union. +const VISIBILITY_LABEL_KEYS = { + unlisted: "share.visibilityUnlistedShort", + public: "share.visibilityPublicShort", + private: "share.visibilityPrivateShort", +} as const satisfies Record; + +const ROLE_LABEL_KEYS = { + view: "share.roleViewShort", + comment: "share.roleCommentShort", + edit: "share.roleEditShort", +} as const satisfies Record; + export function ShareProjectDialog({ open, onOpenChange, currentTitle, getProject, }: ShareProjectDialogProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const shareToken = useDesktopSettingsStore((s) => s.desktopSettings.shareToken); const [tab, setTab] = useState<"create" | "manage">("create"); const [title, setTitle] = useState(""); @@ -69,12 +86,50 @@ export function ShareProjectDialog({ const [activeShares, setActiveShares] = useState([]); const [loadingShares, setLoadingShares] = useState(false); + const [sharesError, setSharesError] = useState(null); const [revokingId, setRevokingId] = useState(null); const [revokeError, setRevokeError] = useState(null); const abortRef = useRef(null); + const sharesAbortRef = useRef(null); const copyTimeoutRef = useRef(null); + // Load (or reload) the Manage tab's list. Each load supersedes the previous + // one: the dialog can be closed, reopened, or handed a freshly edited token + // before an in-flight request resolves, and without cancelling, the older + // response could land last and overwrite the newer list — or write state + // after the dialog is gone. + const loadActiveShares = useCallback( + async (token: string) => { + sharesAbortRef.current?.abort(); + const controller = new AbortController(); + sharesAbortRef.current = controller; + setLoadingShares(true); + setSharesError(null); + try { + const shares = await fetchProjectShares({ token, signal: controller.signal }); + if (sharesAbortRef.current !== controller) return; + setActiveShares(shares); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + if (sharesAbortRef.current !== controller) return; + // An empty list and a failed fetch are not the same thing: swallowing the + // error would render an expired token or a dropped connection as the + // reassuring "no active share links" empty state. + setActiveShares([]); + setSharesError(err instanceof Error ? err.message : t("share.sharesErrorFallback")); + } finally { + // Only the load that is still current clears the spinner, so a + // superseded request never hides the newer one's progress. + if (sharesAbortRef.current === controller) { + sharesAbortRef.current = null; + setLoadingShares(false); + } + } + }, + [t], + ); + // Reset transient state whenever the dialog is (re)opened so a prior result or // error never lingers into a new share. Seed the title from the current // project name, but leave it blank when the project still has its default @@ -93,27 +148,21 @@ export function ShareProjectDialog({ setCopied(false); setTab("create"); setRevokeError(null); + setSharesError(null); + setActiveShares([]); + setLoadingShares(false); if (shareToken.trim()) { - loadActiveShares(shareToken); + void loadActiveShares(shareToken); } } else { abortRef.current?.abort(); abortRef.current = null; - } - }, [open, currentTitle, shareToken]); - - const loadActiveShares = async (token: string) => { - setLoadingShares(true); - try { - const shares = await fetchProjectShares({ token }); - setActiveShares(shares); - } catch { - setActiveShares([]); - } finally { + sharesAbortRef.current?.abort(); + sharesAbortRef.current = null; setLoadingShares(false); } - }; + }, [open, currentTitle, shareToken, loadActiveShares]); // Cancel a pending "copied" reset if the dialog unmounts mid-window. useEffect( @@ -129,6 +178,8 @@ export function ShareProjectDialog({ const titleValid = isShareableTitle(title); const handleShare = async () => { + // Guard re-entry synchronously: a second click before the disabled state + // renders would otherwise start a concurrent, non-idempotent upload. if (abortRef.current) return; setError(null); setErrorCode(null); @@ -148,9 +199,11 @@ export function ShareProjectDialog({ signal: controller.signal, }); setResult(uploaded); - loadActiveShares(shareToken); + void loadActiveShares(shareToken); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; + // A missing account username gets dedicated, actionable UI (a deep link to + // the website's settings) rather than the raw server string. if (err instanceof ShareUploadError && err.code === "username-required") { setErrorCode("username-required"); setError(null); @@ -159,6 +212,8 @@ export function ShareProjectDialog({ setErrorCode(null); } } finally { + // Only the controller that is still current clears state, so an aborted + // (superseded) request never flips a newer one back to idle. if (abortRef.current === controller) { abortRef.current = null; setStatus("idle"); @@ -167,18 +222,25 @@ export function ShareProjectDialog({ }; const handleRevoke = async (shareId: string) => { + // Revoking is immediate and irreversible — the link stops working for + // everyone it was sent to — so a stray click on the icon-only button must + // not be enough to do it. `window.confirm` is blocking and matches how the + // rest of the app gates destructive actions. + if (!window.confirm(t("share.revokeConfirm"))) return; setRevokingId(shareId); setRevokeError(null); try { await revokeShare({ token: shareToken, shareId }); setActiveShares((prev) => prev.filter((s) => s.id !== shareId)); } catch (err) { - setRevokeError(err instanceof Error ? err.message : "Failed to revoke share"); + setRevokeError(err instanceof Error ? err.message : t("share.revokeErrorFallback")); } finally { setRevokingId(null); } }; + // Close this dialog and deep-link into Settings → Environment Variables with + // the share token field focused, so the user can paste the token right away. const handleConfigureToken = () => { onOpenChange(false); openSettingsSection("environment", { focus: "shareToken" }); @@ -187,6 +249,9 @@ export function ShareProjectDialog({ const handleCopy = (url?: string) => { const targetUrl = url || result?.projectUrl; if (!targetUrl) return; + // Only show the "copied" checkmark if the write actually succeeds; the + // promise rejects when clipboard permission is denied or the page is + // unfocused, and swallowing it would flip the icon misleadingly. navigator.clipboard .writeText(targetUrl) .then(() => { @@ -196,7 +261,9 @@ export function ShareProjectDialog({ setCopied(true); copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 2000); }) - .catch(() => {}); + .catch(() => { + // Clipboard unavailable; leave the icon unchanged. + }); }; return ( @@ -433,14 +500,26 @@ export function ShareProjectDialog({ {revokeError}

)} + {sharesError && ( +

+ {sharesError} +

+ )} {loadingShares ? (
) : activeShares.length === 0 ? ( -

- {t("share.noActiveShares", "No active share links found.")} -

+ // Suppress the reassuring empty state when the list failed to + // load; the error above already explains why it is empty. + sharesError ? null : ( +

+ {t("share.noActiveShares")} +

+ ) ) : (
{activeShares.map((s) => ( @@ -451,22 +530,25 @@ export function ShareProjectDialog({

{s.title || s.projectSlug}

- {s.visibility} + {t(VISIBILITY_LABEL_KEYS[s.visibility])} - {s.role} + {t(ROLE_LABEL_KEYS[s.role])} {s.hasPassword && ( <> - Password + {t("share.passwordProtected")} )} {s.expiresAt && ( <> - Expires {new Date(s.expiresAt).toLocaleDateString()} + + {t("share.expires")}{" "} + {new Date(s.expiresAt).toLocaleDateString(i18n.language)} + )}
@@ -486,8 +568,12 @@ export function ShareProjectDialog({ type="button" variant="destructive" size="sm" + aria-label={ + revokingId === s.id ? t("share.revoking") : t("share.revoke") + } + title={t("share.revoke")} disabled={revokingId === s.id} - onClick={() => handleRevoke(s.id)} + onClick={() => void handleRevoke(s.id)} > {revokingId === s.id ? ( diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index baefcb41b..62b90d561 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -1092,10 +1092,16 @@ "visibilityUnlisted": "Unlisted (anyone with the link)", "visibilityPublic": "Public (listed in the gallery)", "visibilityPrivate": "Private (only you)", + "visibilityUnlistedShort": "Unlisted", + "visibilityPublicShort": "Public", + "visibilityPrivateShort": "Private", "role": "Access role", "roleView": "View (read-only)", "roleComment": "Comment (view & comments)", "roleEdit": "Edit (full app)", + "roleViewShort": "View", + "roleCommentShort": "Comment", + "roleEditShort": "Edit", "expiry": "Link expiry", "expiryNever": "Never", "expiry24h": "24 hours", @@ -1106,8 +1112,11 @@ "activeShares": "Active Shares", "createShare": "New Share", "noActiveShares": "No active share links found.", + "sharesErrorFallback": "Could not load your active share links.", "revoke": "Revoke", "revoking": "Revoking…", + "revokeConfirm": "Revoke this share link? Anyone you sent it to will lose access immediately, and this cannot be undone.", + "revokeErrorFallback": "Could not revoke the share link.", "passwordProtected": "Password protected", "expires": "Expires", "shareButton": "Share", diff --git a/apps/geolibre-desktop/src/lib/project-url.ts b/apps/geolibre-desktop/src/lib/project-url.ts index 237f69c1c..b5ec58c67 100644 --- a/apps/geolibre-desktop/src/lib/project-url.ts +++ b/apps/geolibre-desktop/src/lib/project-url.ts @@ -1,4 +1,5 @@ import { parseProject, type GeoLibreProject } from "@geolibre/core"; +import type { ShareRole } from "./share-geolibre"; import { normalizeProjectUrl } from "./urls"; import { WHITEBOX_TOOL_PARAM } from "./whitebox-tool-url"; @@ -6,17 +7,6 @@ import { WHITEBOX_TOOL_PARAM } from "./whitebox-tool-url"; // `?https://...` query (no key) is also accepted by `projectUrlFromLocation`. export const PROJECT_URL_PARAMS = ["url", "project", "projectUrl", "project_url"]; -/** - * Reads a `.geolibre.json` project URL from the current `window.location` query - * string, if one is present. - * - * Accepts any of {@link PROJECT_URL_PARAMS} or a bare `?https://...` query, and - * normalizes the value via `normalizeProjectUrl` (absolute http/https only). - * - * @returns The normalized project URL, or `null` when none is present or valid. - */ -import type { ShareRole } from "./share-geolibre"; - /** * Parses a share role string ("view", "comment", "edit") into a valid ShareRole or null. */ diff --git a/apps/geolibre-desktop/src/lib/share-geolibre.ts b/apps/geolibre-desktop/src/lib/share-geolibre.ts index 7a4df97d6..b37e96b3a 100644 --- a/apps/geolibre-desktop/src/lib/share-geolibre.ts +++ b/apps/geolibre-desktop/src/lib/share-geolibre.ts @@ -268,15 +268,22 @@ export async function fetchProjectShares(options: FetchSharesOptions): Promise
({}))) as { shares?: unknown[] }; const rawShares = Array.isArray(payload.shares) ? payload.shares : []; return rawShares - .map((item: any) => { + .map((raw) => { + const item = (raw ?? {}) as Record; + // Unparseable access-control metadata fails closed to the least + // privileged role, so a server that adds a role this build doesn't know + // never gets displayed as full edit access. const role: ShareRole = item.role === "view" || item.role === "comment" || item.role === "edit" ? item.role - : "edit"; + : "view"; const visibility: ShareVisibility = item.visibility === "public" || item.visibility === "private" ? item.visibility : "unlisted"; + const projectUrl = String( + item.projectUrl || `${base}/u/${encodeURIComponent(String(item.slug ?? ""))}`, + ); return { id: String(item.id || ""), projectSlug: String(item.projectSlug || item.slug || ""), @@ -286,8 +293,11 @@ export async function fetchProjectShares(options: FetchSharesOptions): Promise s.id !== ""); @@ -354,8 +364,10 @@ export async function verifySharePassword( method: "POST", headers: { "Content-Type": "application/json", - "X-Share-Password": options.password, }, + // The password travels in the request body only. Sending it a second time + // as a custom header would widen its exposure for nothing: proxy and + // logging layers routinely capture headers separately from bodies. body: JSON.stringify({ password: options.password }), signal, }); diff --git a/tests/share-geolibre.test.ts b/tests/share-geolibre.test.ts index 9ff46364f..db7b12ee2 100644 --- a/tests/share-geolibre.test.ts +++ b/tests/share-geolibre.test.ts @@ -280,6 +280,44 @@ describe("fetchProjectShares", () => { it("rejects when no token is provided", async () => { await assert.rejects(() => fetchProjectShares({ token: " " }), /token/i); }); + + it("fails closed to the view role when the server sends an unknown one", async () => { + const { fn } = fakeFetch(200, { + shares: [ + { id: "s1", slug: "my-map" }, + { id: "s2", slug: "other-map", role: "owner" }, + ], + }); + + const shares = await fetchProjectShares({ + token: "glb_secrettoken", + baseUrl: "https://share.geolibre.app", + fetchImpl: fn, + }); + + assert.equal(shares.length, 2); + // A missing or unrecognized role must never be displayed as full edit access. + assert.equal(shares[0].role, "view"); + assert.equal(shares[1].role, "view"); + }); + + it("percent-encodes the project URL in the fallback viewer link", async () => { + const { fn } = fakeFetch(200, { + shares: [{ id: "s1", projectUrl: "https://example.com/p?a=1&b=2#frag" }], + }); + + const shares = await fetchProjectShares({ + token: "glb_secrettoken", + baseUrl: "https://share.geolibre.app", + fetchImpl: fn, + }); + + // Without encoding, the raw `&` and `#` would truncate the viewer link. + assert.equal( + shares[0].viewerUrl, + "https://share.geolibre.app/viewer?url=https%3A%2F%2Fexample.com%2Fp%3Fa%3D1%26b%3D2%23frag", + ); + }); }); describe("revokeShare", () => { From d047b002d8b771b3f238d955de390b0c98058224 Mon Sep 17 00:00:00 2001 From: AyushDubey23 Date: Thu, 30 Jul 2026 20:25:38 +0530 Subject: [PATCH 4/4] fix(share): handle 404 as revoke error, normalize roles fail-closed, add aria-label --- .../components/layout/ShareProjectDialog.tsx | 1 + .../src/lib/share-geolibre.ts | 17 ++--- package-lock.json | 64 +++---------------- tests/share-geolibre.test.ts | 29 +++++++++ 4 files changed, 47 insertions(+), 64 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx index 7968b9cd3..aa912056c 100644 --- a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx @@ -559,6 +559,7 @@ export function ShareProjectDialog({ type="button" variant="secondary" size="sm" + aria-label={t("share.copyLink")} title={t("share.copyLink")} onClick={() => handleCopy(s.projectUrl)} > diff --git a/apps/geolibre-desktop/src/lib/share-geolibre.ts b/apps/geolibre-desktop/src/lib/share-geolibre.ts index b37e96b3a..faac6f63c 100644 --- a/apps/geolibre-desktop/src/lib/share-geolibre.ts +++ b/apps/geolibre-desktop/src/lib/share-geolibre.ts @@ -226,6 +226,10 @@ export async function uploadProjectToShare( }; } +export function normalizeShareRole(value: unknown): ShareRole { + return value === "view" || value === "comment" || value === "edit" ? value : "view"; +} + export interface FetchSharesOptions { token: string; baseUrl?: string; @@ -273,10 +277,7 @@ export async function fetchProjectShares(options: FetchSharesOptions): Promise { if (response.status === 401 || response.status === 403) { throw new Error("Invalid or expired API token."); } - if (!response.ok && response.status !== 404) { + if (!response.ok) { throw new Error(`Failed to revoke share (HTTP ${response.status}).`); } } @@ -354,7 +355,7 @@ export interface VerifySharePasswordOptions { export async function verifySharePassword( options: VerifySharePasswordOptions, ): Promise<{ projectContent: string; role?: ShareRole }> { - const fetchImpl = options.fetchImpl ?? fetch; + const fetchImpl = options.fetchImpl ?? getShareFetch(); const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS); const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; @@ -383,10 +384,10 @@ export async function verifySharePassword( throw new Error(`Password verification failed (HTTP ${response.status}).`); } - const data = (await response.json()) as { content?: string; role?: ShareRole }; + const data = (await response.json()) as { content?: string; role?: unknown }; return { projectContent: typeof data.content === "string" ? data.content : JSON.stringify(data), - role: data.role, + role: data.role === undefined ? undefined : normalizeShareRole(data.role), }; } diff --git a/package-lock.json b/package-lock.json index 53a169ffc..0c009d89e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "geolibre", - "version": "2.3.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "geolibre", - "version": "2.3.0", + "version": "2.4.0", "workspaces": [ "apps/*", "packages/*", @@ -26,7 +26,7 @@ } }, "apps/geolibre-desktop": { - "version": "2.3.0", + "version": "2.4.0", "dependencies": { "@anthropic-ai/sdk": "^0.115.0", "@carbonplan/zarr-layer": "^0.7.0", @@ -4212,9 +4212,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4232,9 +4229,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4252,9 +4246,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4272,9 +4263,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4292,9 +4280,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4312,9 +4297,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4332,9 +4314,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4352,9 +4331,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4372,9 +4348,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4398,9 +4371,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4424,9 +4394,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4450,9 +4417,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4476,9 +4440,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4502,9 +4463,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4528,9 +4486,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4554,9 +4509,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -21720,7 +21672,7 @@ }, "packages/core": { "name": "@geolibre/core", - "version": "2.3.0", + "version": "2.4.0", "dependencies": { "@maplibre/maplibre-gl-style-spec": "^26.2.1", "uuid": "^14.0.1", @@ -21800,7 +21752,7 @@ }, "packages/map": { "name": "@geolibre/map", - "version": "2.3.0", + "version": "2.4.0", "dependencies": { "@geolibre/core": "*", "@maplibre/geojson-vt": "^6.1.1", @@ -21869,7 +21821,7 @@ }, "packages/plugins": { "name": "@geolibre/plugins", - "version": "2.3.0", + "version": "2.4.0", "dependencies": { "@carbonplan/zarr-layer": "^0.7.0", "@deck.gl/aggregation-layers": "9.3.7", @@ -22016,7 +21968,7 @@ }, "packages/processing": { "name": "@geolibre/processing", - "version": "2.3.0", + "version": "2.4.0", "dependencies": { "@bjorn3/browser_wasi_shim": "^0.4.2", "@geolibre/core": "*", @@ -22088,7 +22040,7 @@ }, "packages/ui": { "name": "@geolibre/ui", - "version": "2.3.0", + "version": "2.4.0", "dependencies": { "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-direction": "^1.1.4", diff --git a/tests/share-geolibre.test.ts b/tests/share-geolibre.test.ts index db7b12ee2..b29f60acf 100644 --- a/tests/share-geolibre.test.ts +++ b/tests/share-geolibre.test.ts @@ -6,6 +6,7 @@ import { fetchProjectShares, isShareableTitle, MAX_PROJECT_TITLE_LENGTH, + normalizeShareRole, resolveShareBaseUrl, revokeShare, ShareUploadError, @@ -320,6 +321,20 @@ describe("fetchProjectShares", () => { }); }); +describe("normalizeShareRole", () => { + it("passes through valid share roles", () => { + assert.equal(normalizeShareRole("view"), "view"); + assert.equal(normalizeShareRole("comment"), "comment"); + assert.equal(normalizeShareRole("edit"), "edit"); + }); + + it("fails closed to view for unknown or missing values", () => { + assert.equal(normalizeShareRole("owner"), "view"); + assert.equal(normalizeShareRole(null), "view"); + assert.equal(normalizeShareRole(undefined), "view"); + }); +}); + describe("revokeShare", () => { it("deletes the specified share", async () => { const { fn, calls } = fakeFetch(200, { ok: true }); @@ -338,6 +353,20 @@ describe("revokeShare", () => { it("rejects when no token is provided", async () => { await assert.rejects(() => revokeShare({ token: "", shareId: "s1" }), /token/i); }); + + it("rejects when revocation returns 404", async () => { + const { fn } = fakeFetch(404, { error: "Not found" }); + await assert.rejects( + () => + revokeShare({ + token: "glb_secrettoken", + shareId: "s1", + baseUrl: "https://share.geolibre.app", + fetchImpl: fn, + }), + /Failed to revoke share \(HTTP 404\)/i, + ); + }); }); describe("verifySharePassword", () => {