Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ services:
OTEL_SERVICE_NAME: file-service
S3_BUCKET: otterworks-files
DYNAMODB_TABLE: otterworks-file-metadata
DYNAMODB_FOLDER_SHARE_LINKS_TABLE: otterworks-folder-share-links
PUBLIC_WEB_URL: http://localhost:3000
SNS_TOPIC_ARN: arn:aws:sns:us-east-1:000000000000:otterworks-events
SEARCH_SERVICE_URL: http://search-service:8087
# Failed uploads fire a Grafana-style alert at admin-service's ingest
Expand Down Expand Up @@ -556,4 +558,3 @@ networks:
volumes:
postgres_data: null
localstack_data: null

2 changes: 2 additions & 0 deletions frontend/client-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import StarredPage from "@/pages/starred";
import TrashPage from "@/pages/trash";
import TermsPage from "@/pages/terms";
import PrivacyPage from "@/pages/privacy";
import SharedFolderPage from "@/pages/shared-folder";
import BillingPlansPage from "@/features/billing/plans-page";
import BillingEntitlementPage from "@/features/billing/entitlement-page";
import BillingChangePlanPage from "@/features/billing/change-plan-page";
Expand Down Expand Up @@ -62,6 +63,7 @@ export default function App() {
<Route path="/trash" element={<TrashPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/shared/folder/:token" element={<SharedFolderPage />} />
{BILLING_FIXTURE_ENABLED && (
<>
<Route path="/billing/plans" element={<BillingPlansPage />} />
Expand Down
174 changes: 174 additions & 0 deletions frontend/client-app/src/components/files/folder-share-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, X } from "lucide-react";
import toast from "react-hot-toast";
import { filesApi } from "@/lib/api";
import { formatRelativeTime } from "@/lib/utils";

interface FolderShareDialogProps {
folderId: string;
folderName: string;
onClose: () => void;
}

function formatExpiry(date: string): string {
const seconds = Math.round((new Date(date).getTime() - Date.now()) / 1000);
const absolute = Math.abs(seconds);
const unit = absolute >= 86_400 ? "day" : absolute >= 3_600 ? "hour" : "minute";
const divisor = unit === "day" ? 86_400 : unit === "hour" ? 3_600 : 60;
const value = Math.round(seconds / divisor);
return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(value, unit);
}

export function FolderShareDialog({
folderId,
folderName,
onClose,
}: FolderShareDialogProps) {
const queryClient = useQueryClient();
const [expiresInHours, setExpiresInHours] = useState(24);
const [copiedId, setCopiedId] = useState<string | null>(null);

const linksQuery = useQuery({
queryKey: ["folder-share-links", folderId],
queryFn: () => filesApi.listFolderShareLinks(folderId),
});

const createMutation = useMutation({
mutationFn: () => filesApi.createFolderShareLink(folderId, expiresInHours),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["folder-share-links", folderId] });
toast.success("Link created");
},
onError: () => toast.error("Failed to create link"),
});

const revokeMutation = useMutation({
mutationFn: (linkId: string) => filesApi.revokeFolderShareLink(folderId, linkId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["folder-share-links", folderId] });
toast.success("Link revoked");
},
onError: () => toast.error("Failed to revoke link"),
});

const copyLink = async (id: string, url: string) => {
try {
await navigator.clipboard.writeText(url);
setCopiedId(id);
toast.success("Link copied");
window.setTimeout(() => setCopiedId(null), 2000);
} catch {
toast.error("Failed to copy link");
}
};

return (
<>
<div className="fixed inset-0 z-40 bg-black/40" onClick={onClose} />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="w-full max-w-lg rounded-2xl bg-white shadow-2xl"
data-testid="folder-share-dialog"
onClick={(event) => event.stopPropagation()}
>
<header className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div className="flex items-center gap-2">
<Link2 size={18} className="text-otter-600" />
<h2 className="text-lg font-semibold text-gray-900">
Share &ldquo;{folderName}&rdquo;
</h2>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
aria-label="Close"
>
<X size={18} />
</button>
</header>

<div className="space-y-5 px-6 py-5">
<div className="flex items-end gap-3">
<label className="flex-1 text-sm font-medium text-gray-700">
Link expires in
<select
value={expiresInHours}
onChange={(event) => setExpiresInHours(Number(event.target.value))}
className="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-otter-500 focus:outline-none focus:ring-2 focus:ring-otter-500"
data-testid="share-link-expiry"
>
<option value={1}>1 hour</option>
<option value={24}>24 hours</option>
<option value={168}>7 days</option>
<option value={720}>30 days</option>
</select>
</label>
<button
type="button"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
className="rounded-lg bg-otter-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-otter-700 disabled:cursor-not-allowed disabled:opacity-60"
data-testid="create-share-link"
>
{createMutation.isPending ? "Creating…" : "Create link"}
</button>
</div>

<div>
<h3 className="mb-2 text-sm font-medium text-gray-700">Active links</h3>
{linksQuery.isLoading ? (
<p className="py-5 text-center text-sm text-gray-500">Loading links…</p>
) : linksQuery.isError ? (
<p className="py-5 text-center text-sm text-red-600">Unable to load links</p>
) : linksQuery.data?.length ? (
<div className="space-y-2">
{linksQuery.data.map((link) => (
<div
key={link.id}
className="flex items-center gap-3 rounded-lg border border-gray-200 px-3 py-2.5"
data-testid="share-link-row"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-800" title={link.url}>
{link.url}
</p>
<p className="text-xs text-gray-500">
Expires {formatExpiry(link.expiresAt)} · Created{" "}
{formatRelativeTime(link.createdAt)}
</p>
</div>
<button
type="button"
onClick={() => copyLink(link.id, link.url)}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-otter-50 hover:text-otter-600"
aria-label="Copy share link"
data-testid="copy-share-link"
>
{copiedId === link.id ? <Check size={16} /> : <Copy size={16} />}
</button>
<button
type="button"
onClick={() => revokeMutation.mutate(link.id)}
disabled={revokeMutation.isPending}
className="text-xs font-medium text-red-600 hover:text-red-700 disabled:opacity-50"
data-testid="revoke-share-link"
>
Revoke
</button>
</div>
))}
</div>
) : (
<p className="rounded-lg border border-dashed border-gray-300 py-6 text-center text-sm text-gray-500">
No active links
</p>
)}
</div>
</div>
</div>
</div>
</>
);
}
90 changes: 88 additions & 2 deletions frontend/client-app/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isAxiosError } from "axios";
import { apiClient } from "./api-client";
import axios, { isAxiosError } from "axios";
import { apiClient, API_BASE_URL } from "./api-client";
import type {
User,
AuthTokens,
Expand All @@ -15,6 +15,8 @@ import type {
UserSettings,
PaginatedResponse,
SharedUser,
FolderShareLink,
SharedFolderView,
} from "@/types";

// Shape after the axios camelCase interceptor transforms the file-service response
Expand Down Expand Up @@ -49,6 +51,63 @@ interface RawFileListResponse {
pageSize: number;
}

interface RawFolderShareLink {
id: string;
folderId: string;
ownerId: string;
token: string;
expiresAt: string;
createdAt: string;
revoked: boolean;
url: string;
}

function mapFolderShareLink(raw: RawFolderShareLink): FolderShareLink {
return {
id: raw.id,
folderId: raw.folderId,
token: raw.token,
expiresAt: raw.expiresAt,
createdAt: raw.createdAt,
url: raw.url,
};
}

function mapSharedFolderView(raw: {
folder: Record<string, unknown>;
files: Array<Record<string, unknown>>;
expires_at: string;
}): SharedFolderView {
const folder = raw.folder;
return {
folder: normalizeFileItem({
id: folder.id,
name: folder.name,
parentId: folder.parent_id ?? null,
ownerId: folder.owner_id,
createdAt: folder.created_at,
updatedAt: folder.updated_at,
isFolder: true,
}),
files: (raw.files ?? []).map((file) =>
mapRawFile({
id: file.id as string,
name: file.name as string,
mimeType: file.mime_type as string,
sizeBytes: file.size_bytes as number,
s3Key: file.s3_key as string,
folderId: (file.folder_id ?? null) as string | null,
ownerId: file.owner_id as string,
version: file.version as number,
isTrashed: file.is_trashed as boolean,
createdAt: file.created_at as string,
updatedAt: file.updated_at as string,
})
),
expiresAt: raw.expires_at,
};
}

// Normalize a single file from the file-service format to the frontend FileItem shape
function mapRawFile(raw: RawFileItem): FileItem {
return {
Expand Down Expand Up @@ -251,6 +310,33 @@ export const filesApi = {
deleteFolder: async (id: string): Promise<void> => {
await apiClient.delete(`/folders/${id}`);
},
createFolderShareLink: async (
folderId: string,
expiresInHours: number
): Promise<FolderShareLink> => {
const { data } = await apiClient.post<RawFolderShareLink>(
`/folders/${folderId}/share-links`,
{ expires_in_hours: expiresInHours }
);
return mapFolderShareLink(data);
},
listFolderShareLinks: async (folderId: string): Promise<FolderShareLink[]> => {
const { data } = await apiClient.get<{ links: RawFolderShareLink[] }>(
`/folders/${folderId}/share-links`
);
return (data.links ?? []).map(mapFolderShareLink);
},
revokeFolderShareLink: async (folderId: string, linkId: string): Promise<void> => {
await apiClient.delete(`/folders/${folderId}/share-links/${linkId}`);
},
getSharedFolder: async (token: string): Promise<SharedFolderView> => {
const { data } = await axios.get<{
folder: Record<string, unknown>;
files: Array<Record<string, unknown>>;
expires_at: string;
}>(`${API_BASE_URL}/folders/shared/${encodeURIComponent(token)}`);
return mapSharedFolderView(data);
Comment on lines +332 to +338

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Bare Axios mapping is internally consistent

The bare client preserves snake_case fields, which mapSharedFolderView maps explicitly. It also avoids the authenticated client's token verification and redirect behavior.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

},
share: async (id: string, email: string, permission: "view" | "edit"): Promise<void> => {
// An email that doesn't resolve to an OtterWorks user is still sent to
// file-service (as shared_with_email), which decides whether to reject it.
Expand Down
14 changes: 14 additions & 0 deletions frontend/client-app/src/pages/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { FolderCard } from "@/components/files/folder-card";
import { FileUploadDropzone } from "@/components/files/file-upload-dropzone";
import type { FileUploadDropzoneHandle } from "@/components/files/file-upload-dropzone";
import { ShareDialog } from "@/components/files/share-dialog";
import { FolderShareDialog } from "@/components/files/folder-share-dialog";
import { PageLoader } from "@/components/ui/loading-spinner";
import { FileGridSkeleton, FileListSkeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
Expand Down Expand Up @@ -61,6 +62,7 @@ function FileBrowserContent() {
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [shareFileId, setShareFileId] = useState<string | null>(null);
const [shareFolderId, setShareFolderId] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [selectionActive, setSelectionActive] = useState(false);

Expand Down Expand Up @@ -465,6 +467,7 @@ function FileBrowserContent() {
folder={folder}
view={viewMode}
onDelete={(id) => deleteFolderMutation.mutate(id)}
onShare={(id) => setShareFolderId(id)}
onRename={(id, name) => renameFolderMutation.mutate({ id, name })}
selected={selectedIds.has(folder.id)}
onSelect={toggleSelect}
Expand Down Expand Up @@ -536,6 +539,17 @@ function FileBrowserContent() {
/>
);
})()}
{shareFolderId && (() => {
const shareFolder = folders.find((folder) => folder.id === shareFolderId);
if (!shareFolder) return null;
return (
<FolderShareDialog
folderId={shareFolder.id}
folderName={shareFolder.name}
onClose={() => setShareFolderId(null)}
/>
);
})()}
</div>
);
}
Expand Down
Loading
Loading