diff --git a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx index c692a88ed..aa912056c 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 { useEffect, useRef, useState } from "react"; +import { Check, Copy, ExternalLink, KeyRound, Loader2, Lock, Share2, Trash2 } from "lucide-react"; +import { useCallback, 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, @@ -42,24 +47,89 @@ 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(""); 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 [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 @@ -68,16 +138,31 @@ 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); + setSharesError(null); + setActiveShares([]); + setLoadingShares(false); + + if (shareToken.trim()) { + void loadActiveShares(shareToken); + } } else { abortRef.current?.abort(); abortRef.current = null; + sharesAbortRef.current?.abort(); + sharesAbortRef.current = null; + setLoadingShares(false); } - }, [open, currentTitle]); + }, [open, currentTitle, shareToken, loadActiveShares]); // Cancel a pending "copied" reset if the dialog unmounts mid-window. useEffect( @@ -108,9 +193,13 @@ export function ShareProjectDialog({ filename, content, visibility, + role, + expiresIn: expiresIn !== "never" ? expiresIn : undefined, + password: password.trim() || undefined, signal: controller.signal, }); setResult(uploaded); + 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 @@ -132,6 +221,24 @@ 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 : 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 = () => { @@ -139,13 +246,14 @@ export function ShareProjectDialog({ openSettingsSection("environment", { focus: "shareToken" }); }; - const handleCopy = () => { - if (!result) return; + 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(result.projectUrl) + .writeText(targetUrl) .then(() => { if (copyTimeoutRef.current !== null) { window.clearTimeout(copyTimeoutRef.current); @@ -204,7 +312,7 @@ export function ShareProjectDialog({ type="button" variant="secondary" aria-label={t("share.copyLink")} - onClick={handleCopy} + onClick={() => handleCopy()} > {copied ? : } @@ -225,81 +333,268 @@ 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} +

+ )} + {sharesError && ( +

+ {sharesError} +

+ )} + {loadingShares ? ( +
+ +
+ ) : activeShares.length === 0 ? ( + // 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")} +

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

{s.title || s.projectSlug}

+
+ {t(VISIBILITY_LABEL_KEYS[s.visibility])} + + {t(ROLE_LABEL_KEYS[s.role])} + {s.hasPassword && ( + <> + + + + {t("share.passwordProtected")} + + + )} + {s.expiresAt && ( + <> + + + {t("share.expires")}{" "} + {new Date(s.expiresAt).toLocaleDateString(i18n.language)} + + + )} +
+
+ +
+ + +
+
+ ))} +
)} - -
+ +
+ +
+
+ )} )} diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index bbf911bab..62b90d561 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -1092,6 +1092,33 @@ "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", + "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.", + "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", "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..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,6 +7,25 @@ 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"]; +/** + * 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..faac6f63c 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,180 @@ 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 function normalizeShareRole(value: unknown): ShareRole { + return value === "view" || value === "comment" || value === "edit" ? value : "view"; +} + +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((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 = normalizeShareRole(item.role); + 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 || ""), + title: String(item.title || ""), + visibility, + role, + expiresAt: item.expiresAt ? String(item.expiresAt) : null, + hasPassword: Boolean(item.hasPassword || item.passwordProtected), + createdAt: String(item.createdAt || ""), + projectUrl, + // The project URL becomes a query *value* here, so it has to be + // percent-encoded: a raw `&` or `#` in it would otherwise truncate the + // viewer link at that character. + viewerUrl: String(item.viewerUrl || `${base}/viewer?url=${encodeURIComponent(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) { + 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 ?? 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(`${options.shareUrl.replace(/\/+$/, "")}/access`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + // 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, + }); + } 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?: unknown }; + return { + projectContent: typeof data.content === "string" ? data.content : JSON.stringify(data), + 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/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..b29f60acf 100644 --- a/tests/share-geolibre.test.ts +++ b/tests/share-geolibre.test.ts @@ -3,11 +3,15 @@ import { describe, it } from "node:test"; import { DEFAULT_PROJECT_TITLE, DEFAULT_SHARE_BASE_URL, + fetchProjectShares, isShareableTitle, MAX_PROJECT_TITLE_LENGTH, + normalizeShareRole, resolveShareBaseUrl, + revokeShare, ShareUploadError, uploadProjectToShare, + verifySharePassword, } from "../apps/geolibre-desktop/src/lib/share-geolibre"; const PROJECT_DTO = { @@ -213,4 +217,184 @@ 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); + }); + + 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("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 }); + 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); + }); + + 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", () => { + 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, + ); + }); });