feat(files): Google Drive integration (#49)

* feat(files): wire file browser to Google Drive API

replace mock file data with real Google Drive integration
via domain-wide delegation (service account impersonation).

- add google drive REST API v3 library (JWT auth, drive
  client, rate limiting, token cache)
- add schema: google_auth, google_starred_files tables,
  users.googleEmail column
- add 16+ server actions for full CRUD (browse, upload,
  download, create folders, rename, move, trash/restore)
- add download proxy route with google-native file export
- add folder-by-ID route (/dashboard/files/folder/[id])
- refactor use-files hook to fetch from server actions
  with mock data fallback when disconnected
- update all file browser components (upload, rename,
  move, context menu, breadcrumb, drag-drop, nav)
- add settings UI: connection, shared drive picker,
  user email mapping
- extract shared AES-GCM crypto from netsuite module
- dual permission: compass RBAC + google workspace ACLs

* docs(files): add Google Drive integration guide

covers architecture decisions, permission model,
setup instructions, and known limitations.

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-06 22:18:25 -07:00 committed by GitHub
parent e3b708317c
commit 017b0797c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 8062 additions and 391 deletions

View File

@ -0,0 +1,203 @@
Google Drive Integration
===
Compass connects to a Google Workspace via the Drive API v3, replacing the mock file data with real cloud storage. The integration uses domain-wide delegation, which means a single service account impersonates each Compass user by their Google Workspace email. This is worth understanding because it determines how permissions work, how authentication flows, and why the setup requires Google Workspace admin access.
Why domain-wide delegation
---
There were two realistic options for connecting Compass to Google Drive: OAuth per-user or domain-wide delegation via a service account.
Per-user OAuth would require every Compass user to individually authorize the app through a Google consent screen, creating friction on a construction site where field workers need immediate file access. It also requires managing refresh tokens per user and handling re-authorization when tokens expire.
Domain-wide delegation avoids this entirely. A Google Workspace admin grants the service account access to Drive scopes once, and from that point, every Workspace user is accessible without individual consent flows. The service account impersonates each user by setting the `sub` claim in its JWT, so every API call runs as that specific user with their own Drive permissions. This means Google's native sharing model is preserved - if a user can't access a file in Google Workspace, the API returns 403 to Compass as well.
The tradeoff is that setup requires access to both the Google Cloud Console (to create the service account and enable the Drive API) and the Google Workspace Admin Console (to grant domain-wide delegation scopes). This is a one-time administrative action, but it does require someone with admin access to both systems.
How the two permission layers work
---
Every file operation passes through two gates, and this is the most important architectural decision in the integration.
**Compass RBAC** runs first. The role-based permissions defined in `src/lib/permissions.ts` determine what *type* of operation a user can attempt. Admin users get full CRUD plus approve. Office staff get create, read, update. Field workers get create and read. Client users get read only. This check happens before any Google API call is made, so a field worker can never trigger a delete operation regardless of their Google permissions.
**Google Workspace permissions** run second, implicitly. Because the API call is made as the user (via impersonation), Google enforces whatever sharing and access rules exist in the Workspace. If a file is in a Shared Drive that the user doesn't have access to, Google returns 404. If the user has view-only access to a file, Google rejects a PATCH request. No mapping logic is needed - the impersonation itself is the enforcement mechanism.
This means Compass can restrict operations *beyond* what Google allows (a field worker with Google editor access still can't delete through Compass), but it cannot grant access *beyond* what Google allows (an admin who doesn't have a Google Workspace account can't browse files).
User-to-Google email mapping
---
Most Compass users will have the same email in both systems. For cases where they don't (someone whose Compass email is `nicholai@biohazardvfx.com` but whose Workspace email is different), the `users` table has a `googleEmail` column that overrides the default.
The resolution logic is straightforward: use `googleEmail` if set, otherwise fall back to `email`. If the resolved email doesn't exist in the Workspace, the Google API call fails and Compass returns a user-facing error. An admin can set override emails through the settings UI.
Architecture
---
### Library structure
```
src/lib/google/
config.ts - config types, API URLs, scopes, crypto salt
auth/
service-account.ts - JWT creation and token exchange (RS256 via Web Crypto)
token-cache.ts - per-request in-memory cache (see note below)
client/
drive-client.ts - REST wrapper with retry, rate limiting, impersonation
types.ts - Google Drive API v3 response types
mapper.ts - DriveFile -> FileItem type mapping
```
The library uses the Web Crypto API exclusively for JWT signing, which is the only option on Cloudflare Workers (no Node.js crypto module). The service account's private key (RSA, PEM-encoded) is imported as a CryptoKey and used for RS256 signatures. This works identically in Workers, Node, and Deno.
### Token caching
A note on the token cache: in Cloudflare Workers, each request runs in its own isolate, so an in-memory `Map` resets on every request. The cache is effectively a no-op in production. Each request generates a fresh JWT and exchanges it for an access token. This adds roughly 100ms of latency per request but avoids the complexity of KV-backed caching. If this becomes a measurable problem, the cache can be swapped to Workers KV without changing any calling code.
### Rate limiting and retry
The `DriveClient` uses the same `ConcurrencyLimiter` from the NetSuite integration, defaulting to 10 concurrent requests. On 429 responses, it reduces concurrency automatically. On 401 responses, it clears the cached token and retries (the service account token may have expired). On 5xx responses, it retries with exponential backoff up to 3 attempts.
### Encryption at rest
The service account JSON key is encrypted with AES-256-GCM before storage in D1. The encryption module in `src/lib/crypto.ts` is shared between the Google and NetSuite integrations, parameterized by a salt string so the same encryption key can be used for both without deriving identical keys.
The encryption key itself lives in the Cloudflare Workers environment (`GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY`), not in D1. This means a database export alone doesn't expose the service account credentials.
Database schema
---
Migration `0015_busy_photon.sql` adds two tables and one column:
**google_auth** stores one record per organization containing the encrypted service account key, workspace domain, and optional shared drive selection. The `connectedBy` column tracks which admin set up the connection.
**google_starred_files** stores per-user file stars. These are local to Compass rather than using Google's native starring, because the service account impersonation means each user's stars would actually be the service account's stars. This table solves that by keeping star state in D1.
**users.google_email** is a nullable text column for overriding the default email used for impersonation.
Server actions
---
All 16+ server actions live in `src/app/actions/google-drive.ts` and follow the standard Compass pattern: `"use server"`, authenticate with `requireAuth()`, check permissions with `requirePermission()`, return a discriminated union.
### Connection management (admin only)
- `connectGoogleDrive(keyJson, domain)` - encrypts and stores the service account key, validates by making a test API call
- `disconnectGoogleDrive()` - deletes the google_auth record
- `listAvailableSharedDrives()` - lists drives visible to the service account
- `selectSharedDrive(id, name)` - sets which shared drive to browse by default
### File operations (RBAC-gated)
- `listDriveFiles(folderId?, pageToken?)` - browse a folder
- `listDriveFilesForView(view, pageToken?)` - shared, recent, starred, trash views
- `searchDriveFiles(query)` - fulltext search via Google's `fullText contains` query
- `createDriveFolder(name, parentId?)` - requires `document:create`
- `renameDriveFile(fileId, newName)` - requires `document:update`
- `moveDriveFile(fileId, newParentId, oldParentId)` - requires `document:update`
- `trashDriveFile(fileId)` - requires `document:delete`
- `restoreDriveFile(fileId)` - requires `document:update`
- `getUploadSessionUrl(fileName, mimeType, parentId?)` - initiates a resumable upload session; returns the session URL for client-side upload directly to Google
- `getDriveStorageQuota()` - returns used and total bytes
- `getDriveFileInfo(fileId)` - single file metadata
- `listDriveFolders(parentId?)` - folders only, for the move dialog
### Local operations
- `toggleStarFile(googleFileId)` - D1 operation, no Google API call
- `getStarredFileIds()` - returns starred IDs for current user
- `updateUserGoogleEmail(userId, email)` - admin sets override email
Upload flow
---
Uploads use Google's resumable upload protocol. The server creates an upload session (which returns a time-limited, single-use URL), and the client uploads directly to Google via XHR with progress tracking. This avoids proxying file bytes through the Cloudflare Worker, which has request body size limits and would double the bandwidth cost. The upload URL is scoped to a single file and expires, so exposure is limited.
Download flow
---
Downloads are proxied through the worker at `/api/google/download/[fileId]`. The route authenticates the user, checks permissions, and streams the file content from Google through the response. For Google-native files (Docs, Sheets, Slides), it exports them as PDF or xlsx before streaming. This proxy is necessary because the Google API requires authentication that the browser doesn't have.
File type mapping
---
The mapper in `src/lib/google/mapper.ts` converts Google Drive's MIME types to Compass's `FileType` union. Google-native apps types (`application/vnd.google-apps.*`) get special treatment:
- `apps.folder` -> `"folder"`
- `apps.document` -> `"document"` (opens in Google Docs via `webViewLink`)
- `apps.spreadsheet` -> `"spreadsheet"` (opens in Google Sheets)
- `apps.presentation` -> `"document"` (opens in Google Slides)
Standard MIME types are mapped by prefix (`image/*`, `video/*`, `audio/*`) or by keyword matching for archives, spreadsheets, documents, and code files. Anything unrecognized becomes `"unknown"`.
UI changes
---
### File browser
The `use-files.tsx` hook was rewritten from a pure client-side reducer with mock data to a server-action-backed fetcher. Local state (view mode, sort order, selection, search query) still lives in a reducer. File data comes from async server action calls. When Google Drive isn't connected, the hook falls back to mock data and the browser shows a "Demo Mode" banner prompting the admin to connect.
### Components
Every file operation component (upload, rename, move, new folder, context menu) was updated to call server actions when connected and fall back to the mock dispatch when not. The breadcrumb resolves folder ancestry by walking up the `parents` chain via `getDriveFileInfo()` calls.
### Routes
- `/dashboard/files` - root file browser (shows drive root or shared drive root)
- `/dashboard/files?view=shared|recent|starred|trash` - view filters
- `/dashboard/files/folder/[folderId]` - browse a specific folder by Google Drive ID
### Settings
The settings modal's Integrations tab now includes a Google Drive section with connection status, service account upload dialog, shared drive picker, and user email mapping.
Setup
---
### Google Cloud Console
1. Create a project (or use an existing one)
2. Enable the Google Drive API
3. Create a service account
4. Enable domain-wide delegation on the service account
5. Download the service account JSON key
### Google Workspace Admin Console
1. Go to Security -> API Controls -> Domain-wide Delegation
2. Add the service account's client ID
3. Grant scope: `https://www.googleapis.com/auth/drive`
### Compass
1. Set `GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY` in `.dev.vars` (local) or via `wrangler secret put` (production). This can be any random string - it's used to derive an AES-256 key for encrypting the service account JSON at rest.
2. Run `bun run db:migrate:local` (or `bun run db:migrate:prod` for production)
3. Start the app, go to Settings -> Integrations -> Google Drive
4. Upload the service account JSON key and enter the workspace domain
5. Optionally select a shared drive to scope the file browser
Known limitations
---
**Token caching is per-request.** Each request generates a new service account JWT and exchanges it for an access token. For typical usage this adds negligible latency, but high-traffic deployments might want KV-backed caching.
**Breadcrumb resolution is sequential.** Resolving a folder's ancestry requires one API call per parent level. Most folders are 2-4 levels deep, so this is 200-400ms of async resolution. The UI renders immediately and fills in breadcrumb segments as they resolve.
**Stars are local.** Because impersonation shares a single service account identity, Google's native star feature can't be used per-user. Stars are stored in D1 instead, which means they don't appear in the Google Drive web UI.
**No real-time sync.** Files are fetched on navigation. If someone adds a file through the Google Drive web UI, it appears in Compass on the next folder load. There's no push notification or polling.
**Single-organization model.** The `getOrgGoogleAuth()` helper grabs the first google_auth record without filtering by organization ID. This is correct for the current single-tenant-per-D1-instance architecture but would need a WHERE clause if multi-tenancy is added.

View File

@ -7,6 +7,7 @@ export default defineConfig({
"./src/db/schema-plugins.ts", "./src/db/schema-plugins.ts",
"./src/db/schema-agent.ts", "./src/db/schema-agent.ts",
"./src/db/schema-ai-config.ts", "./src/db/schema-ai-config.ts",
"./src/db/schema-google.ts",
], ],
out: "./drizzle", out: "./drizzle",
dialect: "sqlite", dialect: "sqlite",

23
drizzle/0015_busy_photon.sql Executable file
View File

@ -0,0 +1,23 @@
CREATE TABLE `google_auth` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`service_account_key_encrypted` text NOT NULL,
`workspace_domain` text NOT NULL,
`shared_drive_id` text,
`shared_drive_name` text,
`connected_by` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`connected_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `google_starred_files` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`google_file_id` text NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `users` ADD `google_email` text;

3331
drizzle/meta/0015_snapshot.json Executable file

File diff suppressed because it is too large Load Diff

View File

@ -106,6 +106,13 @@
"when": 1770431392946, "when": 1770431392946,
"tag": "0014_new_giant_girl", "tag": "0014_new_giant_girl",
"breakpoints": true "breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1770439304946,
"tag": "0015_busy_photon",
"breakpoints": true
} }
] ]
} }

1029
src/app/actions/google-drive.ts Executable file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,106 @@
import { NextRequest } from "next/server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { requireAuth } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { getDb } from "@/db"
import { googleAuth } from "@/db/schema-google"
import { decrypt } from "@/lib/crypto"
import {
getGoogleConfig,
getGoogleCryptoSalt,
parseServiceAccountKey,
} from "@/lib/google/config"
import { DriveClient } from "@/lib/google/client/drive-client"
import {
isGoogleNativeFile,
getExportMimeType,
getExportExtension,
} from "@/lib/google/mapper"
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> }
): Promise<Response> {
try {
const user = await requireAuth()
requirePermission(user, "document", "read")
const googleEmail = user.googleEmail ?? user.email
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const config = getGoogleConfig(envRecord)
const db = getDb(env.DB)
const auth = await db
.select()
.from(googleAuth)
.limit(1)
.then(rows => rows[0] ?? null)
if (!auth) {
return new Response("Google Drive not connected", {
status: 404,
})
}
const keyJson = await decrypt(
auth.serviceAccountKeyEncrypted,
config.encryptionKey,
getGoogleCryptoSalt()
)
const serviceAccountKey = parseServiceAccountKey(keyJson)
const client = new DriveClient({ serviceAccountKey })
const { fileId } = await params
// get file metadata to determine type
const fileMeta = await client.getFile(
googleEmail,
fileId
)
let response: Response
let fileName = fileMeta.name
let contentType: string
if (isGoogleNativeFile(fileMeta.mimeType)) {
const exportMime = getExportMimeType(fileMeta.mimeType)
if (!exportMime) {
return new Response("Cannot export this file type", {
status: 400,
})
}
const ext = getExportExtension(fileMeta.mimeType)
fileName = `${fileMeta.name}${ext}`
contentType = exportMime
response = await client.exportFile(
googleEmail,
fileId,
exportMime
)
} else {
contentType = fileMeta.mimeType
response = await client.downloadFile(
googleEmail,
fileId
)
}
if (!response.ok) {
return new Response("Failed to download file", {
status: response.status,
})
}
return new Response(response.body, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
"Cache-Control": "private, max-age=300",
},
})
} catch (err) {
console.error("Download error:", err)
return new Response("Download failed", { status: 500 })
}
}

View File

@ -0,0 +1,16 @@
"use client"
import { Suspense } from "react"
import { useParams } from "next/navigation"
import { FileBrowser } from "@/components/files/file-browser"
export default function FilesFolderPage() {
const params = useParams()
const folderId = params.folderId as string
return (
<Suspense>
<FileBrowser folderId={folderId} />
</Suspense>
)
}

View File

@ -6,7 +6,7 @@ import { FileBrowser } from "@/components/files/file-browser"
export default function FilesPage() { export default function FilesPage() {
return ( return (
<Suspense> <Suspense>
<FileBrowser path={[]} /> <FileBrowser />
</Suspense> </Suspense>
) )
} }

View File

@ -1,8 +1,11 @@
"use client" "use client"
import { useState, useEffect } from "react"
import Link from "next/link" import Link from "next/link"
import { IconChevronRight } from "@tabler/icons-react" import { IconChevronRight } from "@tabler/icons-react"
import { useFiles } from "@/hooks/use-files"
import { getDriveFileInfo } from "@/app/actions/google-drive"
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@ -12,12 +15,112 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb" } from "@/components/ui/breadcrumb"
export function FileBreadcrumb({ path }: { path: string[] }) { type BreadcrumbSegment = {
name: string
folderId: string | null
}
export function FileBreadcrumb({
path,
folderId,
}: {
path?: string[]
folderId?: string
}) {
const { state } = useFiles()
const [segments, setSegments] = useState<
BreadcrumbSegment[]
>([])
// for google drive mode: resolve folder ancestry
useEffect(() => {
if (state.isConnected !== true || !folderId) {
setSegments([])
return
}
let cancelled = false
async function resolve() {
const trail: BreadcrumbSegment[] = []
let currentId: string | null = folderId ?? null
// walk up the parents chain (max 10 deep to prevent infinite loops)
for (let depth = 0; depth < 10 && currentId; depth++) {
try {
const result = await getDriveFileInfo(currentId)
if (!result.success || cancelled) break
trail.unshift({
name: result.file.name,
folderId: currentId,
})
currentId = result.file.parentId
} catch {
break
}
}
if (!cancelled) {
setSegments(trail)
}
}
resolve()
return () => {
cancelled = true
}
}, [folderId, state.isConnected])
// mock data mode: use path array
if (state.isConnected !== true) {
const effectivePath = path ?? []
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
{effectivePath.length === 0 ? (
<BreadcrumbPage>My Files</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href="/dashboard/files">
My Files
</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{effectivePath.map((segment, i) => {
const isLast = i === effectivePath.length - 1
const href = `/dashboard/files/${effectivePath.slice(0, i + 1).join("/")}`
return (
<span key={segment} className="contents">
<BreadcrumbSeparator>
<IconChevronRight size={14} />
</BreadcrumbSeparator>
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>
{segment}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={href}>{segment}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
)
})}
</BreadcrumbList>
</Breadcrumb>
)
}
// google drive mode: use resolved segments
return ( return (
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
{path.length === 0 ? ( {segments.length === 0 && !folderId ? (
<BreadcrumbPage>My Files</BreadcrumbPage> <BreadcrumbPage>My Files</BreadcrumbPage>
) : ( ) : (
<BreadcrumbLink asChild> <BreadcrumbLink asChild>
@ -25,20 +128,26 @@ export function FileBreadcrumb({ path }: { path: string[] }) {
</BreadcrumbLink> </BreadcrumbLink>
)} )}
</BreadcrumbItem> </BreadcrumbItem>
{path.map((segment, i) => { {segments.map((seg, i) => {
const isLast = i === path.length - 1 const isLast = i === segments.length - 1
const href = `/dashboard/files/${path.slice(0, i + 1).join("/")}`
return ( return (
<span key={segment} className="contents"> <span
key={seg.folderId ?? i}
className="contents"
>
<BreadcrumbSeparator> <BreadcrumbSeparator>
<IconChevronRight size={14} /> <IconChevronRight size={14} />
</BreadcrumbSeparator> </BreadcrumbSeparator>
<BreadcrumbItem> <BreadcrumbItem>
{isLast ? ( {isLast ? (
<BreadcrumbPage>{segment}</BreadcrumbPage> <BreadcrumbPage>{seg.name}</BreadcrumbPage>
) : ( ) : (
<BreadcrumbLink asChild> <BreadcrumbLink asChild>
<Link href={href}>{segment}</Link> <Link
href={`/dashboard/files/folder/${seg.folderId}`}
>
{seg.name}
</Link>
</BreadcrumbLink> </BreadcrumbLink>
)} )}
</BreadcrumbItem> </BreadcrumbItem>

View File

@ -1,8 +1,14 @@
"use client" "use client"
import { useState, useCallback } from "react" import { useState, useCallback, useEffect } from "react"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import {
IconCloudOff,
IconAlertTriangle,
IconRefresh,
IconLoader2,
} from "@tabler/icons-react"
import type { FileItem } from "@/lib/files-data" import type { FileItem } from "@/lib/files-data"
import { useFiles, type FileView } from "@/hooks/use-files" import { useFiles, type FileView } from "@/hooks/use-files"
@ -17,23 +23,54 @@ import { FileRenameDialog } from "./file-rename-dialog"
import { FileMoveDialog } from "./file-move-dialog" import { FileMoveDialog } from "./file-move-dialog"
import { FileDropZone } from "./file-drop-zone" import { FileDropZone } from "./file-drop-zone"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Button } from "@/components/ui/button"
export function FileBrowser({ path }: { path: string[] }) { export function FileBrowser({
path,
folderId,
}: {
path?: string[]
folderId?: string
}) {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const viewParam = searchParams.get("view") as FileView | null const viewParam = searchParams.get("view") as FileView | null
const currentView = viewParam ?? "my-files" const currentView = viewParam ?? "my-files"
const { state, dispatch, getFilesForView } = useFiles() const {
const files = getFilesForView(currentView, path) state,
dispatch,
getFilesForView,
fetchFiles,
loadMore,
createFolder,
starFile,
} = useFiles()
const effectivePath = path ?? []
const files = getFilesForView(currentView, effectivePath)
const [uploadOpen, setUploadOpen] = useState(false) const [uploadOpen, setUploadOpen] = useState(false)
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [newFolderOpen, setNewFolderOpen] = useState(false) const [newFolderOpen, setNewFolderOpen] = useState(false)
const [renameFile, setRenameFile] = useState<FileItem | null>(null) const [renameFile, setRenameFile] = useState<FileItem | null>(
null
)
const [moveFile, setMoveFile] = useState<FileItem | null>(null) const [moveFile, setMoveFile] = useState<FileItem | null>(null)
const { handleClick } = useFileSelection(files, state.selectedIds, { const { handleClick } = useFileSelection(
select: (ids) => dispatch({ type: "SET_SELECTED", payload: ids }), files,
}) state.selectedIds,
{
select: ids =>
dispatch({ type: "SET_SELECTED", payload: ids }),
}
)
// fetch files when connected and folder/view changes
useEffect(() => {
if (state.isConnected !== true) return
fetchFiles(folderId, currentView)
}, [state.isConnected, folderId, currentView, fetchFiles])
const handleBackgroundClick = useCallback( const handleBackgroundClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@ -44,45 +81,40 @@ export function FileBrowser({ path }: { path: string[] }) {
[dispatch] [dispatch]
) )
// for mock data mode, resolve parentId from path
const parentId = (() => { const parentId = (() => {
if (path.length === 0) return null if (folderId) return folderId
if (effectivePath.length === 0) return null
const folder = state.files.find( const folder = state.files.find(
(f) => f =>
f.type === "folder" && f.type === "folder" &&
f.name === path[path.length - 1] && f.name === effectivePath[effectivePath.length - 1] &&
JSON.stringify(f.path) === JSON.stringify(path.slice(0, -1)) JSON.stringify(f.path) ===
JSON.stringify(effectivePath.slice(0, -1))
) )
return folder?.id ?? null return folder?.id ?? null
})() })()
const handleNew = useCallback( const handleNew = useCallback(
(type: NewFileType) => { async (type: NewFileType) => {
if (type === "folder") { if (type === "folder") {
setNewFolderOpen(true) setNewFolderOpen(true)
return return
} }
// for google-native doc creation, these would
const names: Record<string, string> = { // be created as google docs/sheets/slides
document: "Untitled Document", toast.info(
spreadsheet: "Untitled Spreadsheet", "Creating Google Workspace files coming soon"
presentation: "Untitled Presentation", )
}
const fileType = type === "presentation" ? "document" : type
dispatch({
type: "CREATE_FILE",
payload: {
name: names[type],
fileType: fileType as FileItem["type"],
parentId,
path,
},
})
toast.success(`${names[type]} created`)
}, },
[dispatch, parentId, path] []
) )
const handleDrop = useCallback((droppedFiles: File[]) => {
setUploadFiles(droppedFiles)
setUploadOpen(true)
}, [])
const viewTitle: Record<FileView, string> = { const viewTitle: Record<FileView, string> = {
"my-files": "", "my-files": "",
shared: "Shared with me", shared: "Shared with me",
@ -91,19 +123,97 @@ export function FileBrowser({ path }: { path: string[] }) {
trash: "Trash", trash: "Trash",
} }
// loading state (only for initial load)
if (state.isConnected === null) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<IconLoader2
size={32}
className="animate-spin"
/>
<p className="text-sm">
Checking connection...
</p>
</div>
</div>
)
}
// error state
if (state.error && state.files.length === 0) {
return (
<div className="flex flex-1 items-center justify-center p-4">
<div className="flex flex-col items-center gap-3 text-center">
<IconAlertTriangle
size={32}
className="text-destructive"
/>
<p className="text-sm text-muted-foreground">
{state.error}
</p>
<Button
variant="outline"
size="sm"
onClick={() =>
fetchFiles(folderId, currentView)
}
>
<IconRefresh size={16} className="mr-2" />
Retry
</Button>
</div>
</div>
)
}
return ( return (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> <div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
{currentView !== "my-files" && ( {/* not connected banner (demo mode) */}
<h1 className="text-lg font-semibold">{viewTitle[currentView]}</h1> {state.isConnected === false && (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50 px-4 py-3">
<IconCloudOff
size={18}
className="text-muted-foreground"
/>
<p className="text-sm text-muted-foreground">
Showing demo files. Connect Google Drive in
Settings to see your real files.
</p>
</div>
)}
{currentView !== "my-files" && (
<h1 className="text-lg font-semibold">
{viewTitle[currentView]}
</h1>
)}
{currentView === "my-files" && (
<FileBreadcrumb
path={effectivePath}
folderId={folderId}
/>
)} )}
{currentView === "my-files" && <FileBreadcrumb path={path} />}
<FileToolbar <FileToolbar
onNew={handleNew} onNew={handleNew}
onUpload={() => setUploadOpen(true)} onUpload={() => {
setUploadFiles([])
setUploadOpen(true)
}}
/> />
<FileDropZone onDrop={() => setUploadOpen(true)}> <FileDropZone onDrop={handleDrop}>
<ScrollArea className="flex-1" onClick={handleBackgroundClick}> <ScrollArea
{state.viewMode === "grid" ? ( className="flex-1"
onClick={handleBackgroundClick}
>
{state.isLoading && state.files.length === 0 ? (
<div className="flex items-center justify-center py-20">
<IconLoader2
size={24}
className="animate-spin text-muted-foreground"
/>
</div>
) : state.viewMode === "grid" ? (
<FileGrid <FileGrid
files={files} files={files}
selectedIds={state.selectedIds} selectedIds={state.selectedIds}
@ -120,27 +230,50 @@ export function FileBrowser({ path }: { path: string[] }) {
onMove={setMoveFile} onMove={setMoveFile}
/> />
)} )}
{state.nextPageToken && (
<div className="flex justify-center py-4">
<Button
variant="outline"
size="sm"
onClick={loadMore}
disabled={state.isLoading}
>
{state.isLoading ? (
<IconLoader2
size={16}
className="mr-2 animate-spin"
/>
) : null}
Load more
</Button>
</div>
)}
</ScrollArea> </ScrollArea>
</FileDropZone> </FileDropZone>
<FileUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} /> <FileUploadDialog
open={uploadOpen}
onOpenChange={setUploadOpen}
files={uploadFiles}
parentId={parentId ?? folderId}
/>
<FileNewFolderDialog <FileNewFolderDialog
open={newFolderOpen} open={newFolderOpen}
onOpenChange={setNewFolderOpen} onOpenChange={setNewFolderOpen}
currentPath={path} currentPath={effectivePath}
parentId={parentId} parentId={parentId ?? folderId ?? null}
/> />
<FileRenameDialog <FileRenameDialog
open={!!renameFile} open={!!renameFile}
onOpenChange={(open) => !open && setRenameFile(null)} onOpenChange={open => !open && setRenameFile(null)}
file={renameFile} file={renameFile}
/> />
<FileMoveDialog <FileMoveDialog
open={!!moveFile} open={!!moveFile}
onOpenChange={(open) => !open && setMoveFile(null)} onOpenChange={open => !open && setMoveFile(null)}
file={moveFile} file={moveFile}
/> />
</div> </div>
) )
} }

View File

@ -3,6 +3,7 @@
import { import {
IconDownload, IconDownload,
IconEdit, IconEdit,
IconExternalLink,
IconFolderSymlink, IconFolderSymlink,
IconShare, IconShare,
IconStar, IconStar,
@ -13,6 +14,7 @@ import {
import type { FileItem } from "@/lib/files-data" import type { FileItem } from "@/lib/files-data"
import { useFiles } from "@/hooks/use-files" import { useFiles } from "@/hooks/use-files"
import { isGoogleNativeFile } from "@/lib/google/mapper"
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@ -33,43 +35,143 @@ export function FileContextMenu({
onRename: (file: FileItem) => void onRename: (file: FileItem) => void
onMove: (file: FileItem) => void onMove: (file: FileItem) => void
}) { }) {
const { dispatch } = useFiles() const {
starFile,
trashFile,
restoreFile,
state,
dispatch,
} = useFiles()
const handleStar = async () => {
if (state.isConnected === true) {
await starFile(file.id)
} else {
dispatch({ type: "OPTIMISTIC_STAR", payload: file.id })
}
}
const handleTrash = async () => {
if (state.isConnected === true) {
const ok = await trashFile(file.id)
if (ok) {
toast.success(`"${file.name}" moved to trash`)
} else {
toast.error("Failed to delete file")
}
} else {
dispatch({
type: "OPTIMISTIC_TRASH",
payload: file.id,
})
toast.success(`"${file.name}" moved to trash`)
}
}
const handleRestore = async () => {
if (state.isConnected === true) {
const ok = await restoreFile(file.id)
if (ok) {
toast.success(`"${file.name}" restored`)
} else {
toast.error("Failed to restore file")
}
} else {
dispatch({
type: "OPTIMISTIC_RESTORE",
payload: file.id,
})
toast.success(`"${file.name}" restored`)
}
}
const handleDownload = () => {
if (state.isConnected === true) {
window.open(
`/api/google/download/${file.id}`,
"_blank"
)
} else {
toast.success("Download started")
}
}
const handleOpenInDrive = () => {
if (file.webViewLink) {
window.open(file.webViewLink, "_blank")
}
}
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger> <ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-48"> <ContextMenuContent className="w-48">
{file.type === "folder" && ( {file.type === "folder" && (
<ContextMenuItem onClick={() => toast.info("Opening folder...")}> <ContextMenuItem
<IconFolderSymlink size={16} className="mr-2" /> onClick={() =>
toast.info("Opening folder...")
}
>
<IconFolderSymlink
size={16}
className="mr-2"
/>
Open Open
</ContextMenuItem> </ContextMenuItem>
)} )}
<ContextMenuItem onClick={() => toast.info("Share dialog coming soon")}> <ContextMenuItem
onClick={() =>
toast.info("Share dialog coming soon")
}
>
<IconShare size={16} className="mr-2" /> <IconShare size={16} className="mr-2" />
Share Share
</ContextMenuItem> </ContextMenuItem>
{!file.trashed && ( {!file.trashed && (
<> <>
<ContextMenuItem onClick={() => toast.success("Download started")}> {file.webViewLink &&
<IconDownload size={16} className="mr-2" /> file.mimeType &&
Download isGoogleNativeFile(file.mimeType) && (
</ContextMenuItem> <ContextMenuItem
onClick={handleOpenInDrive}
>
<IconExternalLink
size={16}
className="mr-2"
/>
Open in Google Drive
</ContextMenuItem>
)}
{file.type !== "folder" && (
<ContextMenuItem onClick={handleDownload}>
<IconDownload
size={16}
className="mr-2"
/>
Download
</ContextMenuItem>
)}
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onClick={() => onRename(file)}> <ContextMenuItem onClick={() => onRename(file)}>
<IconEdit size={16} className="mr-2" /> <IconEdit size={16} className="mr-2" />
Rename Rename
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onClick={() => onMove(file)}> <ContextMenuItem onClick={() => onMove(file)}>
<IconFolderSymlink size={16} className="mr-2" /> <IconFolderSymlink
size={16}
className="mr-2"
/>
Move to Move to
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem onClick={handleStar}>
onClick={() => dispatch({ type: "STAR_FILE", payload: file.id })}
>
{file.starred ? ( {file.starred ? (
<> <>
<IconStarFilled size={16} className="mr-2 text-amber-400" /> <IconStarFilled
size={16}
className="mr-2 text-amber-400"
/>
Unstar Unstar
</> </>
) : ( ) : (
@ -82,10 +184,7 @@ export function FileContextMenu({
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
className="text-destructive" className="text-destructive"
onClick={() => { onClick={handleTrash}
dispatch({ type: "TRASH_FILE", payload: file.id })
toast.success(`"${file.name}" moved to trash`)
}}
> >
<IconTrash size={16} className="mr-2" /> <IconTrash size={16} className="mr-2" />
Delete Delete
@ -93,12 +192,7 @@ export function FileContextMenu({
</> </>
)} )}
{file.trashed && ( {file.trashed && (
<ContextMenuItem <ContextMenuItem onClick={handleRestore}>
onClick={() => {
dispatch({ type: "RESTORE_FILE", payload: file.id })
toast.success(`"${file.name}" restored`)
}}
>
<IconTrashOff size={16} className="mr-2" /> <IconTrashOff size={16} className="mr-2" />
Restore Restore
</ContextMenuItem> </ContextMenuItem>

View File

@ -9,7 +9,7 @@ export function FileDropZone({
onDrop, onDrop,
}: { }: {
children: React.ReactNode children: React.ReactNode
onDrop: () => void onDrop: (files: File[]) => void
}) { }) {
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const [dragCounter, setDragCounter] = useState(0) const [dragCounter, setDragCounter] = useState(0)
@ -17,7 +17,7 @@ export function FileDropZone({
const handleDragEnter = useCallback( const handleDragEnter = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
e.preventDefault() e.preventDefault()
setDragCounter((c) => c + 1) setDragCounter(c => c + 1)
if (e.dataTransfer.types.includes("Files")) { if (e.dataTransfer.types.includes("Files")) {
setDragging(true) setDragging(true)
} }
@ -28,7 +28,7 @@ export function FileDropZone({
const handleDragLeave = useCallback( const handleDragLeave = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
e.preventDefault() e.preventDefault()
setDragCounter((c) => { setDragCounter(c => {
const next = c - 1 const next = c - 1
if (next <= 0) setDragging(false) if (next <= 0) setDragging(false)
return Math.max(0, next) return Math.max(0, next)
@ -37,9 +37,12 @@ export function FileDropZone({
[] []
) )
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback(
e.preventDefault() (e: React.DragEvent) => {
}, []) e.preventDefault()
},
[]
)
const handleDrop = useCallback( const handleDrop = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
@ -47,7 +50,7 @@ export function FileDropZone({
setDragging(false) setDragging(false)
setDragCounter(0) setDragCounter(0)
if (e.dataTransfer.files.length > 0) { if (e.dataTransfer.files.length > 0) {
onDrop() onDrop(Array.from(e.dataTransfer.files))
} }
}, },
[onDrop] [onDrop]
@ -73,7 +76,9 @@ export function FileDropZone({
> >
<div className="flex flex-col items-center gap-2 text-primary"> <div className="flex flex-col items-center gap-2 text-primary">
<IconCloudUpload size={48} strokeWidth={1.5} /> <IconCloudUpload size={48} strokeWidth={1.5} />
<p className="text-sm font-medium">Drop files to upload</p> <p className="text-sm font-medium">
Drop files to upload
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,11 @@
"use client" "use client"
import { forwardRef } from "react" import { forwardRef } from "react"
import { IconStar, IconStarFilled, IconUsers, IconDots } from "@tabler/icons-react" import {
IconStar,
IconStarFilled,
IconUsers,
} from "@tabler/icons-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import type { FileItem as FileItemType } from "@/lib/files-data" import type { FileItem as FileItemType } from "@/lib/files-data"
@ -29,13 +33,32 @@ export const FolderCard = forwardRef<
selected: boolean selected: boolean
onClick: (e: React.MouseEvent) => void onClick: (e: React.MouseEvent) => void
} }
>(function FolderCard({ file, selected, onClick, ...props }, ref) { >(function FolderCard(
{ file, selected, onClick, ...props },
ref
) {
const router = useRouter() const router = useRouter()
const { dispatch } = useFiles() const { starFile, state, dispatch } = useFiles()
const handleDoubleClick = () => { const handleDoubleClick = () => {
const folderPath = [...file.path, file.name].join("/") if (state.isConnected === true) {
router.push(`/dashboard/files/${folderPath}`) router.push(`/dashboard/files/folder/${file.id}`)
} else {
const folderPath = [...file.path, file.name].join("/")
router.push(`/dashboard/files/${folderPath}`)
}
}
const handleStar = async (e: React.MouseEvent) => {
e.stopPropagation()
if (state.isConnected === true) {
await starFile(file.id)
} else {
dispatch({
type: "OPTIMISTIC_STAR",
payload: file.id,
})
}
} }
return ( return (
@ -50,25 +73,37 @@ export const FolderCard = forwardRef<
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
{...props} {...props}
> >
<FileIcon type="folder" size={22} className="shrink-0" /> <FileIcon
<span className="text-sm font-medium line-clamp-2 flex-1 break-words">{file.name}</span> type="folder"
size={22}
className="shrink-0"
/>
<span className="text-sm font-medium line-clamp-2 flex-1 break-words">
{file.name}
</span>
{file.shared && ( {file.shared && (
<IconUsers size={14} className="text-muted-foreground shrink-0" /> <IconUsers
size={14}
className="text-muted-foreground shrink-0"
/>
)} )}
<button <button
className={cn( className={cn(
"shrink-0 opacity-0 group-hover:opacity-100 transition-opacity", "shrink-0 opacity-0 group-hover:opacity-100 transition-opacity",
file.starred && "opacity-100" file.starred && "opacity-100"
)} )}
onClick={(e) => { onClick={handleStar}
e.stopPropagation()
dispatch({ type: "STAR_FILE", payload: file.id })
}}
> >
{file.starred ? ( {file.starred ? (
<IconStarFilled size={14} className="text-amber-400" /> <IconStarFilled
size={14}
className="text-amber-400"
/>
) : ( ) : (
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" /> <IconStar
size={14}
className="text-muted-foreground hover:text-amber-400"
/>
)} )}
</button> </button>
</div> </div>
@ -82,8 +117,23 @@ export const FileCard = forwardRef<
selected: boolean selected: boolean
onClick: (e: React.MouseEvent) => void onClick: (e: React.MouseEvent) => void
} }
>(function FileCard({ file, selected, onClick, ...props }, ref) { >(function FileCard(
const { dispatch } = useFiles() { file, selected, onClick, ...props },
ref
) {
const { starFile, state, dispatch } = useFiles()
const handleStar = async (e: React.MouseEvent) => {
e.stopPropagation()
if (state.isConnected === true) {
await starFile(file.id)
} else {
dispatch({
type: "OPTIMISTIC_STAR",
payload: file.id,
})
}
}
return ( return (
<div <div
@ -99,13 +149,20 @@ export const FileCard = forwardRef<
<div <div
className={cn( className={cn(
"flex items-center justify-center h-20 sm:h-24", "flex items-center justify-center h-20 sm:h-24",
fileTypeColors[file.type] ?? fileTypeColors.unknown fileTypeColors[file.type] ??
fileTypeColors.unknown
)} )}
> >
<FileIcon type={file.type} size={32} className="opacity-70 sm:size-10" /> <FileIcon
type={file.type}
size={32}
className="opacity-70 sm:size-10"
/>
</div> </div>
<div className="flex flex-col gap-1 px-2.5 py-2.5 border-t"> <div className="flex flex-col gap-1 px-2.5 py-2.5 border-t">
<p className="text-sm font-medium line-clamp-2 break-words leading-snug">{file.name}</p> <p className="text-sm font-medium line-clamp-2 break-words leading-snug">
{file.name}
</p>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{formatRelativeDate(file.modifiedAt)} {formatRelativeDate(file.modifiedAt)}
@ -116,15 +173,18 @@ export const FileCard = forwardRef<
"opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0", "opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0",
file.starred && "opacity-100" file.starred && "opacity-100"
)} )}
onClick={(e) => { onClick={handleStar}
e.stopPropagation()
dispatch({ type: "STAR_FILE", payload: file.id })
}}
> >
{file.starred ? ( {file.starred ? (
<IconStarFilled size={14} className="text-amber-400" /> <IconStarFilled
size={14}
className="text-amber-400"
/>
) : ( ) : (
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" /> <IconStar
size={14}
className="text-muted-foreground hover:text-amber-400"
/>
)} )}
</button> </button>
</div> </div>

View File

@ -1,7 +1,11 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect } from "react"
import { IconFolder, IconFolderSymlink } from "@tabler/icons-react" import {
IconFolder,
IconFolderSymlink,
IconLoader2,
} from "@tabler/icons-react"
import type { FileItem } from "@/lib/files-data" import type { FileItem } from "@/lib/files-data"
import { useFiles } from "@/hooks/use-files" import { useFiles } from "@/hooks/use-files"
@ -17,6 +21,8 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
type FolderEntry = { id: string; name: string }
export function FileMoveDialog({ export function FileMoveDialog({
open, open,
onOpenChange, onOpenChange,
@ -26,31 +32,91 @@ export function FileMoveDialog({
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
file: FileItem | null file: FileItem | null
}) { }) {
const { dispatch, getFolders } = useFiles() const {
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null) moveFile: moveFileFn,
fetchFolders,
getFolders,
state,
dispatch,
} = useFiles()
const folders = getFolders().filter((f) => f.id !== file?.id) const [selectedFolderId, setSelectedFolderId] = useState<
string | null
>(null)
const [loading, setLoading] = useState(false)
const [movePending, setMovePending] = useState(false)
const [driveFolders, setDriveFolders] = useState<
FolderEntry[]
>([])
const handleMove = () => { // fetch folders when dialog opens
useEffect(() => {
if (!open) return
if (state.isConnected === true) {
setLoading(true)
fetchFolders().then(folders => {
if (folders) {
setDriveFolders(
folders.filter(f => f.id !== file?.id)
)
}
setLoading(false)
})
}
}, [open, state.isConnected, fetchFolders, file?.id])
const mockFolders = getFolders().filter(
f => f.id !== file?.id
)
const handleMove = async () => {
if (!file) return if (!file) return
const targetFolder = folders.find((f) => f.id === selectedFolderId) setMovePending(true)
const targetPath = targetFolder try {
? [...targetFolder.path, targetFolder.name] if (state.isConnected === true) {
: [] if (!selectedFolderId) {
toast.error("Select a destination folder")
return
}
const oldParentId = file.parentId ?? "root"
const ok = await moveFileFn(
file.id,
selectedFolderId,
oldParentId
)
if (ok) {
const dest = driveFolders.find(
f => f.id === selectedFolderId
)
toast.success(
`Moved "${file.name}" to ${dest?.name ?? "folder"}`
)
} else {
toast.error("Failed to move file")
}
} else {
// mock mode
const targetFolder = mockFolders.find(
f => f.id === selectedFolderId
)
const targetPath = targetFolder
? [...targetFolder.path, targetFolder.name]
: []
dispatch({ dispatch({
type: "MOVE_FILE", type: "REMOVE_FILE",
payload: { payload: file.id,
id: file.id, })
targetFolderId: selectedFolderId, toast.success(
targetPath, `Moved "${file.name}" to ${targetFolder?.name ?? "My Files"}`
}, )
}) }
toast.success( onOpenChange(false)
`Moved "${file.name}" to ${targetFolder?.name ?? "My Files"}` } finally {
) setMovePending(false)
onOpenChange(false) }
} }
return ( return (
@ -62,37 +128,105 @@ export function FileMoveDialog({
Move to Move to
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<ScrollArea className="h-64 rounded-md border p-2">
<button {loading ? (
className={cn( <div className="flex items-center justify-center py-10">
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent", <IconLoader2
selectedFolderId === null && "bg-accent" size={24}
className="animate-spin text-muted-foreground"
/>
</div>
) : (
<ScrollArea className="h-64 rounded-md border p-2">
{state.isConnected === true ? (
<>
{driveFolders.map(folder => (
<button
key={folder.id}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
selectedFolderId === folder.id &&
"bg-accent"
)}
onClick={() =>
setSelectedFolderId(folder.id)
}
>
<IconFolder
size={16}
className="text-amber-500"
/>
{folder.name}
</button>
))}
{driveFolders.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No folders found
</p>
)}
</>
) : (
<>
<button
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
selectedFolderId === null && "bg-accent"
)}
onClick={() => setSelectedFolderId(null)}
>
<IconFolder
size={16}
className="text-amber-500"
/>
My Files (root)
</button>
{mockFolders.map(folder => (
<button
key={folder.id}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
selectedFolderId === folder.id &&
"bg-accent"
)}
style={{
paddingLeft: `${(folder.path.length + 1) * 12 + 12}px`,
}}
onClick={() =>
setSelectedFolderId(folder.id)
}
>
<IconFolder
size={16}
className="text-amber-500"
/>
{folder.name}
</button>
))}
</>
)} )}
onClick={() => setSelectedFolderId(null)} </ScrollArea>
> )}
<IconFolder size={16} className="text-amber-500" />
My Files (root)
</button>
{folders.map((folder) => (
<button
key={folder.id}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
selectedFolderId === folder.id && "bg-accent"
)}
style={{ paddingLeft: `${(folder.path.length + 1) * 12 + 12}px` }}
onClick={() => setSelectedFolderId(folder.id)}
>
<IconFolder size={16} className="text-amber-500" />
{folder.name}
</button>
))}
</ScrollArea>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}> <Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={movePending}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleMove}>Move here</Button> <Button
onClick={handleMove}
disabled={movePending}
>
{movePending && (
<IconLoader2
size={16}
className="mr-2 animate-spin"
/>
)}
Move here
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -1,7 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { IconFolderPlus } from "@tabler/icons-react" import { IconFolderPlus, IconLoader2 } from "@tabler/icons-react"
import { useFiles } from "@/hooks/use-files" import { useFiles } from "@/hooks/use-files"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -27,19 +27,52 @@ export function FileNewFolderDialog({
parentId: string | null parentId: string | null
}) { }) {
const [name, setName] = useState("") const [name, setName] = useState("")
const { dispatch } = useFiles() const [loading, setLoading] = useState(false)
const { createFolder, state, dispatch } = useFiles()
const handleCreate = () => { const handleCreate = async () => {
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) return if (!trimmed) return
dispatch({ setLoading(true)
type: "CREATE_FOLDER", try {
payload: { name: trimmed, parentId, path: currentPath }, if (state.isConnected === true) {
}) const ok = await createFolder(
toast.success(`Folder "${trimmed}" created`) trimmed,
setName("") parentId ?? undefined
onOpenChange(false) )
if (ok) {
toast.success(`Folder "${trimmed}" created`)
} else {
toast.error("Failed to create folder")
}
} else {
// mock mode: local dispatch
dispatch({
type: "OPTIMISTIC_ADD_FOLDER",
payload: {
id: `folder-${Date.now()}`,
name: trimmed,
type: "folder",
size: 0,
path: currentPath,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
owner: { name: "You" },
starred: false,
shared: false,
trashed: false,
parentId,
},
})
toast.success(`Folder "${trimmed}" created`)
}
setName("")
onOpenChange(false)
} finally {
setLoading(false)
}
} }
return ( return (
@ -55,16 +88,32 @@ export function FileNewFolderDialog({
<Input <Input
placeholder="Folder name" placeholder="Folder name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={e => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()} onKeyDown={e =>
e.key === "Enter" && handleCreate()
}
autoFocus autoFocus
disabled={loading}
/> />
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}> <Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleCreate} disabled={!name.trim()}> <Button
onClick={handleCreate}
disabled={!name.trim() || loading}
>
{loading && (
<IconLoader2
size={16}
className="mr-2 animate-spin"
/>
)}
Create Create
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { IconEdit } from "@tabler/icons-react" import { IconEdit, IconLoader2 } from "@tabler/icons-react"
import type { FileItem } from "@/lib/files-data" import type { FileItem } from "@/lib/files-data"
import { useFiles } from "@/hooks/use-files" import { useFiles } from "@/hooks/use-files"
@ -26,20 +26,42 @@ export function FileRenameDialog({
file: FileItem | null file: FileItem | null
}) { }) {
const [name, setName] = useState("") const [name, setName] = useState("")
const { dispatch } = useFiles() const [loading, setLoading] = useState(false)
const {
renameFile: renameFileFn,
state,
dispatch,
} = useFiles()
useEffect(() => { useEffect(() => {
if (file) setName(file.name) if (file) setName(file.name)
}, [file]) }, [file])
const handleRename = () => { const handleRename = async () => {
if (!file) return if (!file) return
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed || trimmed === file.name) return if (!trimmed || trimmed === file.name) return
dispatch({ type: "RENAME_FILE", payload: { id: file.id, name: trimmed } }) setLoading(true)
toast.success(`Renamed to "${trimmed}"`) try {
onOpenChange(false) if (state.isConnected === true) {
const ok = await renameFileFn(file.id, trimmed)
if (ok) {
toast.success(`Renamed to "${trimmed}"`)
} else {
toast.error("Failed to rename")
}
} else {
dispatch({
type: "OPTIMISTIC_RENAME",
payload: { id: file.id, name: trimmed },
})
toast.success(`Renamed to "${trimmed}"`)
}
onOpenChange(false)
} finally {
setLoading(false)
}
} }
return ( return (
@ -54,19 +76,36 @@ export function FileRenameDialog({
<div className="py-2"> <div className="py-2">
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={e => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleRename()} onKeyDown={e =>
e.key === "Enter" && handleRename()
}
autoFocus autoFocus
disabled={loading}
/> />
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}> <Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleRename} onClick={handleRename}
disabled={!name.trim() || name.trim() === file?.name} disabled={
!name.trim() ||
name.trim() === file?.name ||
loading
}
> >
{loading && (
<IconLoader2
size={16}
className="mr-2 animate-spin"
/>
)}
Rename Rename
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -2,10 +2,17 @@
import { forwardRef } from "react" import { forwardRef } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { IconStar, IconStarFilled, IconUsers } from "@tabler/icons-react" import {
IconStar,
IconStarFilled,
IconUsers,
} from "@tabler/icons-react"
import type { FileItem } from "@/lib/files-data" import type { FileItem } from "@/lib/files-data"
import { formatFileSize, formatRelativeDate } from "@/lib/file-utils" import {
formatFileSize,
formatRelativeDate,
} from "@/lib/file-utils"
import { FileIcon } from "./file-icon" import { FileIcon } from "./file-icon"
import { useFiles } from "@/hooks/use-files" import { useFiles } from "@/hooks/use-files"
import { TableCell, TableRow } from "@/components/ui/table" import { TableCell, TableRow } from "@/components/ui/table"
@ -18,14 +25,35 @@ export const FileRow = forwardRef<
selected: boolean selected: boolean
onClick: (e: React.MouseEvent) => void onClick: (e: React.MouseEvent) => void
} }
>(function FileRow({ file, selected, onClick, ...props }, ref) { >(function FileRow(
{ file, selected, onClick, ...props },
ref
) {
const router = useRouter() const router = useRouter()
const { dispatch } = useFiles() const { starFile, state, dispatch } = useFiles()
const handleDoubleClick = () => { const handleDoubleClick = () => {
if (file.type === "folder") { if (file.type === "folder") {
const folderPath = [...file.path, file.name].join("/") if (state.isConnected === true) {
router.push(`/dashboard/files/${folderPath}`) router.push(`/dashboard/files/folder/${file.id}`)
} else {
const folderPath = [...file.path, file.name].join(
"/"
)
router.push(`/dashboard/files/${folderPath}`)
}
}
}
const handleStar = async (e: React.MouseEvent) => {
e.stopPropagation()
if (state.isConnected === true) {
await starFile(file.id)
} else {
dispatch({
type: "OPTIMISTIC_STAR",
payload: file.id,
})
} }
} }
@ -43,8 +71,15 @@ export const FileRow = forwardRef<
<TableCell className="w-[40%]"> <TableCell className="w-[40%]">
<div className="flex items-center gap-2.5 min-w-0"> <div className="flex items-center gap-2.5 min-w-0">
<FileIcon type={file.type} size={18} /> <FileIcon type={file.type} size={18} />
<span className="truncate text-sm font-medium">{file.name}</span> <span className="truncate text-sm font-medium">
{file.shared && <IconUsers size={13} className="text-muted-foreground shrink-0" />} {file.name}
</span>
{file.shared && (
<IconUsers
size={13}
className="text-muted-foreground shrink-0"
/>
)}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
@ -54,7 +89,9 @@ export const FileRow = forwardRef<
{file.owner.name} {file.owner.name}
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{file.type === "folder" ? "—" : formatFileSize(file.size)} {file.type === "folder"
? "—"
: formatFileSize(file.size)}
</TableCell> </TableCell>
<TableCell className="w-8"> <TableCell className="w-8">
<button <button
@ -62,15 +99,18 @@ export const FileRow = forwardRef<
"opacity-0 group-hover:opacity-100 transition-opacity", "opacity-0 group-hover:opacity-100 transition-opacity",
file.starred && "opacity-100" file.starred && "opacity-100"
)} )}
onClick={(e) => { onClick={handleStar}
e.stopPropagation()
dispatch({ type: "STAR_FILE", payload: file.id })
}}
> >
{file.starred ? ( {file.starred ? (
<IconStarFilled size={14} className="text-amber-400" /> <IconStarFilled
size={14}
className="text-amber-400"
/>
) : ( ) : (
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" /> <IconStar
size={14}
className="text-muted-foreground hover:text-amber-400"
/>
)} )}
</button> </button>
</TableCell> </TableCell>

View File

@ -1,51 +1,229 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useCallback, useRef } from "react"
import { IconUpload } from "@tabler/icons-react" import { IconUpload, IconFile, IconCheck, IconX } from "@tabler/icons-react"
import { useFiles } from "@/hooks/use-files"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { Button } from "@/components/ui/button"
import { toast } from "sonner" import { toast } from "sonner"
import { formatFileSize } from "@/lib/file-utils"
type UploadItem = {
file: File
progress: number
status: "pending" | "uploading" | "done" | "error"
error?: string
}
export function FileUploadDialog({ export function FileUploadDialog({
open, open,
onOpenChange, onOpenChange,
files: initialFiles,
parentId,
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
files?: File[]
parentId?: string | null
}) { }) {
const [progress, setProgress] = useState(0) const { getUploadUrl, state, fetchFiles } = useFiles()
const [uploads, setUploads] = useState<UploadItem[]>([])
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setProgress(0) setUploads([])
setUploading(false) setUploading(false)
return return
} }
setUploading(true) if (initialFiles && initialFiles.length > 0) {
const interval = setInterval(() => { setUploads(
setProgress((prev) => { initialFiles.map(f => ({
if (prev >= 100) { file: f,
clearInterval(interval) progress: 0,
setTimeout(() => { status: "pending" as const,
onOpenChange(false) }))
toast.success("File uploaded successfully") )
}, 300) }
return 100 }, [open, initialFiles])
}
return prev + Math.random() * 15
})
}, 200)
return () => clearInterval(interval) const handleFileSelect = useCallback(
}, [open, onOpenChange]) (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files
if (!selected) return
const newUploads: UploadItem[] = Array.from(
selected
).map(f => ({
file: f,
progress: 0,
status: "pending" as const,
}))
setUploads(prev => [...prev, ...newUploads])
},
[]
)
const uploadSingleFile = useCallback(
async (item: UploadItem, index: number) => {
setUploads(prev =>
prev.map((u, i) =>
i === index ? { ...u, status: "uploading" } : u
)
)
try {
if (state.isConnected !== true) {
// mock mode: fake progress
for (let p = 0; p <= 100; p += 20) {
await new Promise(r => setTimeout(r, 100))
setUploads(prev =>
prev.map((u, i) =>
i === index
? { ...u, progress: Math.min(p, 100) }
: u
)
)
}
setUploads(prev =>
prev.map((u, i) =>
i === index
? { ...u, status: "done", progress: 100 }
: u
)
)
return
}
const uploadUrl = await getUploadUrl(
item.file.name,
item.file.type || "application/octet-stream",
parentId ?? undefined
)
if (!uploadUrl) {
throw new Error("Failed to get upload URL")
}
// upload directly to google using XHR for progress
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("PUT", uploadUrl)
xhr.setRequestHeader(
"Content-Type",
item.file.type || "application/octet-stream"
)
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round(
(e.loaded / e.total) * 100
)
setUploads(prev =>
prev.map((u, i) =>
i === index
? { ...u, progress: pct }
: u
)
)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(
new Error(
`Upload failed: ${xhr.status}`
)
)
}
}
xhr.onerror = () =>
reject(new Error("Upload failed"))
xhr.send(item.file)
})
setUploads(prev =>
prev.map((u, i) =>
i === index
? { ...u, status: "done", progress: 100 }
: u
)
)
} catch (err) {
setUploads(prev =>
prev.map((u, i) =>
i === index
? {
...u,
status: "error",
error:
err instanceof Error
? err.message
: "Upload failed",
}
: u
)
)
}
},
[getUploadUrl, parentId, state.isConnected]
)
const handleUpload = useCallback(async () => {
if (uploads.length === 0) return
setUploading(true)
for (let i = 0; i < uploads.length; i++) {
if (uploads[i].status === "pending") {
await uploadSingleFile(uploads[i], i)
}
}
setUploading(false)
const allDone = uploads.every(
u => u.status === "done" || u.status === "error"
)
if (allDone) {
const successCount = uploads.filter(
u => u.status === "done"
).length
if (successCount > 0) {
toast.success(
`${successCount} file${successCount > 1 ? "s" : ""} uploaded`
)
// refresh file list
if (state.isConnected === true) {
await fetchFiles(parentId ?? undefined)
}
}
setTimeout(() => onOpenChange(false), 500)
}
}, [
uploads,
uploadSingleFile,
onOpenChange,
state.isConnected,
fetchFiles,
parentId,
])
const removeUpload = useCallback((index: number) => {
setUploads(prev => prev.filter((_, i) => i !== index))
}, [])
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@ -53,23 +231,117 @@ export function FileUploadDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<IconUpload size={18} /> <IconUpload size={18} />
Uploading file Upload files
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-2"> <div className="space-y-3 py-2">
<div className="flex items-center justify-between text-sm"> {uploads.length === 0 && (
<span className="text-muted-foreground">example-file.pdf</span> <div
<span className="text-muted-foreground"> className="flex flex-col items-center gap-3 rounded-lg border-2 border-dashed p-8 cursor-pointer hover:border-primary/50"
{Math.min(100, Math.round(progress))}% onClick={() => fileInputRef.current?.click()}
</span> >
</div> <IconUpload
<Progress value={Math.min(100, progress)} className="h-2" /> size={32}
{uploading && progress < 100 && ( className="text-muted-foreground"
<p className="text-xs text-muted-foreground"> />
Uploading to cloud storage... <p className="text-sm text-muted-foreground">
</p> Click to select files or drag and drop
</p>
</div>
)} )}
{uploads.map((item, i) => (
<div key={i} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 min-w-0">
{item.status === "done" ? (
<IconCheck
size={14}
className="shrink-0 text-green-500"
/>
) : item.status === "error" ? (
<IconX
size={14}
className="shrink-0 text-destructive"
/>
) : (
<IconFile
size={14}
className="shrink-0 text-muted-foreground"
/>
)}
<span className="truncate">
{item.file.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted-foreground">
{formatFileSize(item.file.size)}
</span>
{item.status === "pending" &&
!uploading && (
<button
onClick={() => removeUpload(i)}
className="text-muted-foreground hover:text-foreground"
>
<IconX size={14} />
</button>
)}
</div>
</div>
{(item.status === "uploading" ||
item.status === "done") && (
<Progress
value={item.progress}
className="h-1.5"
/>
)}
{item.error && (
<p className="text-xs text-destructive">
{item.error}
</p>
)}
</div>
))}
</div> </div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<DialogFooter>
{uploads.length > 0 && !uploading && (
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
Add more
</Button>
)}
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={uploading}
>
Cancel
</Button>
<Button
onClick={handleUpload}
disabled={
uploads.length === 0 ||
uploading ||
uploads.every(u => u.status === "done")
}
>
{uploading ? "Uploading..." : "Upload"}
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )

View File

@ -0,0 +1,180 @@
"use client"
import { useState, useRef } from "react"
import {
IconBrandGoogleDrive,
IconUpload,
IconLoader2,
IconCheck,
} from "@tabler/icons-react"
import { connectGoogleDrive } from "@/app/actions/google-drive"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"
import { toast } from "sonner"
export function GoogleConnectDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [keyFile, setKeyFile] = useState<string | null>(null)
const [keyFileName, setKeyFileName] = useState("")
const [domain, setDomain] = useState("")
const [loading, setLoading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileSelect = (
e: React.ChangeEvent<HTMLInputElement>
) => {
const file = e.target.files?.[0]
if (!file) return
setKeyFileName(file.name)
const reader = new FileReader()
reader.onload = ev => {
const content = ev.target?.result
if (typeof content === "string") {
setKeyFile(content)
// try to extract domain hint from client_email
try {
const parsed = JSON.parse(content)
if (parsed.client_email) {
// not setting domain automatically - admin needs to enter workspace domain
}
} catch {
// ignore
}
}
}
reader.readAsText(file)
}
const handleConnect = async () => {
if (!keyFile || !domain.trim()) return
setLoading(true)
const result = await connectGoogleDrive(
keyFile,
domain.trim()
)
if (result.success) {
toast.success("Google Drive connected")
setKeyFile(null)
setKeyFileName("")
setDomain("")
onOpenChange(false)
} else {
toast.error(result.error)
}
setLoading(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconBrandGoogleDrive size={20} />
Connect Google Drive
</DialogTitle>
<DialogDescription>
Upload your Google service account JSON key and
enter your workspace domain.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label className="text-xs">
Service Account Key (JSON)
</Label>
<div
className="flex items-center gap-2 rounded-lg border border-dashed p-3 cursor-pointer hover:border-primary/50"
onClick={() => fileInputRef.current?.click()}
>
{keyFile ? (
<>
<IconCheck
size={16}
className="text-green-500 shrink-0"
/>
<span className="text-sm truncate">
{keyFileName}
</span>
</>
) : (
<>
<IconUpload
size={16}
className="text-muted-foreground shrink-0"
/>
<span className="text-sm text-muted-foreground">
Click to select JSON key file
</span>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileSelect}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">
Workspace Domain
</Label>
<Input
placeholder="e.g. openrangeconstruction.ltd"
value={domain}
onChange={e => setDomain(e.target.value)}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
The Google Workspace domain your organization
uses.
</p>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button
onClick={handleConnect}
disabled={!keyFile || !domain.trim() || loading}
>
{loading && (
<IconLoader2
size={16}
className="mr-2 animate-spin"
/>
)}
Connect
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,155 @@
"use client"
import { useState, useEffect } from "react"
import {
IconBrandGoogleDrive,
IconCheck,
IconX,
IconLoader2,
} from "@tabler/icons-react"
import {
getGoogleDriveConnectionStatus,
disconnectGoogleDrive,
} from "@/app/actions/google-drive"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { GoogleConnectDialog } from "./connect-dialog"
import { SharedDrivePicker } from "./shared-drive-picker"
export function GoogleDriveConnectionStatus() {
const [status, setStatus] = useState<{
connected: boolean
workspaceDomain: string | null
sharedDriveName: string | null
} | null>(null)
const [loading, setLoading] = useState(true)
const [connectOpen, setConnectOpen] = useState(false)
const [pickerOpen, setPickerOpen] = useState(false)
const [disconnecting, setDisconnecting] = useState(false)
const fetchStatus = async () => {
setLoading(true)
const result = await getGoogleDriveConnectionStatus()
setStatus(result)
setLoading(false)
}
useEffect(() => {
fetchStatus()
}, [])
const handleDisconnect = async () => {
setDisconnecting(true)
const result = await disconnectGoogleDrive()
if (result.success) {
toast.success("Google Drive disconnected")
await fetchStatus()
} else {
toast.error(result.error)
}
setDisconnecting(false)
}
if (loading) {
return (
<div className="flex items-center gap-3 rounded-lg border p-4">
<IconLoader2
size={20}
className="animate-spin text-muted-foreground"
/>
<span className="text-sm text-muted-foreground">
Checking Google Drive connection...
</span>
</div>
)
}
return (
<>
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<IconBrandGoogleDrive size={20} />
<span className="text-sm font-medium">
Google Drive
</span>
{status?.connected ? (
<Badge
variant="outline"
className="gap-1 text-green-600 border-green-200"
>
<IconCheck size={12} />
Connected
</Badge>
) : (
<Badge
variant="outline"
className="gap-1 text-muted-foreground"
>
<IconX size={12} />
Not connected
</Badge>
)}
</div>
</div>
{status?.connected && (
<div className="space-y-1 text-xs text-muted-foreground">
<p>Domain: {status.workspaceDomain}</p>
{status.sharedDriveName && (
<p>Shared drive: {status.sharedDriveName}</p>
)}
</div>
)}
<div className="flex gap-2">
{status?.connected ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
>
Change shared drive
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDisconnect}
disabled={disconnecting}
>
{disconnecting
? "Disconnecting..."
: "Disconnect"}
</Button>
</>
) : (
<Button
size="sm"
onClick={() => setConnectOpen(true)}
>
Connect Google Drive
</Button>
)}
</div>
</div>
<GoogleConnectDialog
open={connectOpen}
onOpenChange={open => {
setConnectOpen(open)
if (!open) fetchStatus()
}}
/>
<SharedDrivePicker
open={pickerOpen}
onOpenChange={open => {
setPickerOpen(open)
if (!open) fetchStatus()
}}
/>
</>
)
}

View File

@ -0,0 +1,161 @@
"use client"
import { useState, useEffect } from "react"
import {
IconFolder,
IconLoader2,
IconFolderShare,
} from "@tabler/icons-react"
import {
listAvailableSharedDrives,
selectSharedDrive,
} from "@/app/actions/google-drive"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export function SharedDrivePicker({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [drives, setDrives] = useState<
ReadonlyArray<{ id: string; name: string }>
>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [selectedId, setSelectedId] = useState<
string | null
>(null)
useEffect(() => {
if (!open) return
setLoading(true)
listAvailableSharedDrives().then(result => {
if (result.success) {
setDrives(result.drives)
} else {
toast.error(result.error)
}
setLoading(false)
})
}, [open])
const handleSelect = async () => {
setSaving(true)
const driveName =
selectedId === null
? null
: drives.find(d => d.id === selectedId)?.name ??
null
const result = await selectSharedDrive(
selectedId,
driveName
)
if (result.success) {
toast.success(
selectedId
? `Using shared drive "${driveName}"`
: "Using root drive"
)
onOpenChange(false)
} else {
toast.error(result.error)
}
setSaving(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconFolderShare size={18} />
Select Shared Drive
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-10">
<IconLoader2
size={24}
className="animate-spin text-muted-foreground"
/>
</div>
) : (
<ScrollArea className="h-64 rounded-md border p-2">
<button
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
selectedId === null && "bg-accent"
)}
onClick={() => setSelectedId(null)}
>
<IconFolder
size={16}
className="text-amber-500"
/>
My Drive (root)
</button>
{drives.map(drive => (
<button
key={drive.id}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent",
selectedId === drive.id && "bg-accent"
)}
onClick={() => setSelectedId(drive.id)}
>
<IconFolderShare
size={16}
className="text-blue-500"
/>
{drive.name}
</button>
))}
{drives.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No shared drives found.
</p>
)}
</ScrollArea>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button
onClick={handleSelect}
disabled={loading || saving}
>
{saving && (
<IconLoader2
size={16}
className="mr-2 animate-spin"
/>
)}
Select
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,77 @@
"use client"
import { useState } from "react"
import { IconLoader2, IconMail } from "@tabler/icons-react"
import { updateUserGoogleEmail } from "@/app/actions/google-drive"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "sonner"
export function UserEmailMapping({
userId,
currentEmail,
googleEmail,
}: {
userId: string
currentEmail: string
googleEmail: string | null
}) {
const [email, setEmail] = useState(googleEmail ?? "")
const [loading, setLoading] = useState(false)
const handleSave = async () => {
setLoading(true)
const value = email.trim() || null
const result = await updateUserGoogleEmail(userId, value)
if (result.success) {
toast.success(
value
? `Google email set to ${value}`
: "Google email override removed"
)
} else {
toast.error(result.error)
}
setLoading(false)
}
return (
<div className="space-y-2 rounded-lg border p-4">
<div className="flex items-center gap-2">
<IconMail size={16} className="text-muted-foreground" />
<Label className="text-xs font-medium">
Google Workspace Email Override
</Label>
</div>
<p className="text-xs text-muted-foreground">
Default: {currentEmail}. Set a different email if
your Google Workspace account uses a different
address.
</p>
<div className="flex gap-2">
<Input
placeholder="Leave empty to use default email"
value={email}
onChange={e => setEmail(e.target.value)}
className="h-8 text-sm"
disabled={loading}
/>
<Button
size="sm"
onClick={handleSave}
disabled={loading}
>
{loading && (
<IconLoader2
size={14}
className="mr-1 animate-spin"
/>
)}
Save
</Button>
</div>
</div>
)
}

View File

@ -11,7 +11,7 @@ import {
import Link from "next/link" import Link from "next/link"
import { usePathname, useSearchParams } from "next/navigation" import { usePathname, useSearchParams } from "next/navigation"
import { mockStorageUsage } from "@/lib/files-data" import { useFiles } from "@/hooks/use-files"
import { StorageIndicator } from "@/components/files/storage-indicator" import { StorageIndicator } from "@/components/files/storage-indicator"
import { import {
SidebarGroup, SidebarGroup,
@ -22,11 +22,24 @@ import {
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type FileView = "my-files" | "shared" | "recent" | "starred" | "trash" type FileView =
| "my-files"
| "shared"
| "recent"
| "starred"
| "trash"
const fileNavItems: { title: string; view: FileView; icon: typeof IconFiles }[] = [ const fileNavItems: {
title: string
view: FileView
icon: typeof IconFiles
}[] = [
{ title: "My Files", view: "my-files", icon: IconFiles }, { title: "My Files", view: "my-files", icon: IconFiles },
{ title: "Shared with me", view: "shared", icon: IconUsers }, {
title: "Shared with me",
view: "shared",
icon: IconUsers,
},
{ title: "Recent", view: "recent", icon: IconClock }, { title: "Recent", view: "recent", icon: IconClock },
{ title: "Starred", view: "starred", icon: IconStar }, { title: "Starred", view: "starred", icon: IconStar },
{ title: "Trash", view: "trash", icon: IconTrash }, { title: "Trash", view: "trash", icon: IconTrash },
@ -36,6 +49,7 @@ export function NavFiles() {
const pathname = usePathname() const pathname = usePathname()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const activeView = searchParams.get("view") ?? "my-files" const activeView = searchParams.get("view") ?? "my-files"
const { storageUsage } = useFiles()
return ( return (
<> <>
@ -43,7 +57,10 @@ export function NavFiles() {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Back to Dashboard"> <SidebarMenuButton
asChild
tooltip="Back to Dashboard"
>
<Link href="/dashboard"> <Link href="/dashboard">
<IconArrowLeft /> <IconArrowLeft />
<span>Back</span> <span>Back</span>
@ -56,14 +73,16 @@ export function NavFiles() {
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{fileNavItems.map((item) => ( {fileNavItems.map(item => (
<SidebarMenuItem key={item.view}> <SidebarMenuItem key={item.view}>
<SidebarMenuButton <SidebarMenuButton
asChild asChild
tooltip={item.title} tooltip={item.title}
className={cn( className={cn(
activeView === item.view && activeView === item.view &&
pathname?.startsWith("/dashboard/files") && pathname?.startsWith(
"/dashboard/files"
) &&
"bg-sidebar-foreground/10 font-medium" "bg-sidebar-foreground/10 font-medium"
)} )}
> >
@ -84,7 +103,7 @@ export function NavFiles() {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<div className="mt-auto px-3 pb-3"> <div className="mt-auto px-3 pb-3">
<StorageIndicator usage={mockStorageUsage} /> <StorageIndicator usage={storageUsage} />
</div> </div>
</> </>
) )

View File

@ -25,6 +25,7 @@ import {
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status" import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status"
import { SyncControls } from "@/components/netsuite/sync-controls" import { SyncControls } from "@/components/netsuite/sync-controls"
import { GoogleDriveConnectionStatus } from "@/components/google/connection-status"
import { MemoriesTable } from "@/components/agent/memories-table" import { MemoriesTable } from "@/components/agent/memories-table"
import { SkillsTab } from "@/components/settings/skills-tab" import { SkillsTab } from "@/components/settings/skills-tab"
import { AIModelTab } from "@/components/settings/ai-model-tab" import { AIModelTab } from "@/components/settings/ai-model-tab"
@ -150,6 +151,8 @@ export function SettingsModal({
const integrationsPage = ( const integrationsPage = (
<> <>
<GoogleDriveConnectionStatus />
<Separator />
<NetSuiteConnectionStatus /> <NetSuiteConnectionStatus />
<SyncControls /> <SyncControls />
</> </>
@ -309,6 +312,8 @@ export function SettingsModal({
value="integrations" value="integrations"
className="space-y-3 pt-3" className="space-y-3 pt-3"
> >
<GoogleDriveConnectionStatus />
<Separator />
<NetSuiteConnectionStatus /> <NetSuiteConnectionStatus />
<SyncControls /> <SyncControls />
</TabsContent> </TabsContent>

View File

@ -4,6 +4,7 @@ import * as netsuiteSchema from "./schema-netsuite"
import * as pluginSchema from "./schema-plugins" import * as pluginSchema from "./schema-plugins"
import * as agentSchema from "./schema-agent" import * as agentSchema from "./schema-agent"
import * as aiConfigSchema from "./schema-ai-config" import * as aiConfigSchema from "./schema-ai-config"
import * as googleSchema from "./schema-google"
const allSchemas = { const allSchemas = {
...schema, ...schema,
@ -11,6 +12,7 @@ const allSchemas = {
...pluginSchema, ...pluginSchema,
...agentSchema, ...agentSchema,
...aiConfigSchema, ...aiConfigSchema,
...googleSchema,
} }
export function getDb(d1: D1Database) { export function getDb(d1: D1Database) {

34
src/db/schema-google.ts Executable file
View File

@ -0,0 +1,34 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { users, organizations } from "./schema"
export const googleAuth = sqliteTable("google_auth", {
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organizations.id, { onDelete: "cascade" }),
serviceAccountKeyEncrypted: text(
"service_account_key_encrypted"
).notNull(),
workspaceDomain: text("workspace_domain").notNull(),
sharedDriveId: text("shared_drive_id"),
sharedDriveName: text("shared_drive_name"),
connectedBy: text("connected_by")
.notNull()
.references(() => users.id),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
})
export const googleStarredFiles = sqliteTable("google_starred_files", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
googleFileId: text("google_file_id").notNull(),
createdAt: text("created_at").notNull(),
})
export type GoogleAuth = typeof googleAuth.$inferSelect
export type NewGoogleAuth = typeof googleAuth.$inferInsert
export type GoogleStarredFile = typeof googleStarredFiles.$inferSelect
export type NewGoogleStarredFile = typeof googleStarredFiles.$inferInsert

View File

@ -14,6 +14,7 @@ export const users = sqliteTable("users", {
displayName: text("display_name"), displayName: text("display_name"),
avatarUrl: text("avatar_url"), avatarUrl: text("avatar_url"),
role: text("role").notNull().default("office"), // admin, office, field, client role: text("role").notNull().default("office"), // admin, office, field, client
googleEmail: text("google_email"), // override for google workspace impersonation
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastLoginAt: text("last_login_at"), lastLoginAt: text("last_login_at"),
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),

View File

@ -5,9 +5,31 @@ import {
useContext, useContext,
useReducer, useReducer,
useCallback, useCallback,
useEffect,
useRef,
type ReactNode, type ReactNode,
} from "react" } from "react"
import { mockFiles, mockStorageUsage, type FileItem } from "@/lib/files-data" import {
mockFiles,
mockStorageUsage,
type FileItem,
type StorageUsage,
} from "@/lib/files-data"
import {
getGoogleDriveConnectionStatus,
listDriveFiles,
listDriveFilesForView,
searchDriveFiles,
createDriveFolder,
renameDriveFile,
moveDriveFile,
trashDriveFile,
restoreDriveFile,
toggleStarFile,
getDriveStorageQuota,
getUploadSessionUrl,
listDriveFolders,
} from "@/app/actions/google-drive"
export type FileView = export type FileView =
| "my-files" | "my-files"
@ -28,6 +50,11 @@ type FilesState = {
sortDirection: SortDirection sortDirection: SortDirection
searchQuery: string searchQuery: string
files: FileItem[] files: FileItem[]
isConnected: boolean | null
isLoading: boolean
error: string | null
storageQuota: StorageUsage
nextPageToken: string | null
} }
type FilesAction = type FilesAction =
@ -36,22 +63,41 @@ type FilesAction =
| { type: "SET_SELECTED"; payload: Set<string> } | { type: "SET_SELECTED"; payload: Set<string> }
| { type: "TOGGLE_SELECTED"; payload: string } | { type: "TOGGLE_SELECTED"; payload: string }
| { type: "CLEAR_SELECTION" } | { type: "CLEAR_SELECTION" }
| { type: "SET_SORT"; payload: { field: SortField; direction: SortDirection } } | {
type: "SET_SORT"
payload: { field: SortField; direction: SortDirection }
}
| { type: "SET_SEARCH"; payload: string } | { type: "SET_SEARCH"; payload: string }
| { type: "STAR_FILE"; payload: string } | { type: "SET_FILES"; payload: FileItem[] }
| { type: "TRASH_FILE"; payload: string } | { type: "APPEND_FILES"; payload: FileItem[] }
| { type: "RESTORE_FILE"; payload: string } | { type: "SET_LOADING"; payload: boolean }
| { type: "RENAME_FILE"; payload: { id: string; name: string } } | { type: "SET_ERROR"; payload: string | null }
| { type: "CREATE_FOLDER"; payload: { name: string; parentId: string | null; path: string[] } } | { type: "SET_CONNECTED"; payload: boolean }
| { type: "CREATE_FILE"; payload: { name: string; fileType: FileItem["type"]; parentId: string | null; path: string[] } } | { type: "SET_STORAGE_QUOTA"; payload: StorageUsage }
| { type: "MOVE_FILE"; payload: { id: string; targetFolderId: string | null; targetPath: string[] } } | { type: "SET_NEXT_PAGE_TOKEN"; payload: string | null }
| { type: "OPTIMISTIC_STAR"; payload: string }
| { type: "OPTIMISTIC_TRASH"; payload: string }
| { type: "OPTIMISTIC_RESTORE"; payload: string }
| {
type: "OPTIMISTIC_RENAME"
payload: { id: string; name: string }
}
| { type: "OPTIMISTIC_ADD_FOLDER"; payload: FileItem }
| { type: "REMOVE_FILE"; payload: string }
function filesReducer(state: FilesState, action: FilesAction): FilesState { function filesReducer(
state: FilesState,
action: FilesAction
): FilesState {
switch (action.type) { switch (action.type) {
case "SET_VIEW_MODE": case "SET_VIEW_MODE":
return { ...state, viewMode: action.payload } return { ...state, viewMode: action.payload }
case "SET_CURRENT_VIEW": case "SET_CURRENT_VIEW":
return { ...state, currentView: action.payload, selectedIds: new Set() } return {
...state,
currentView: action.payload,
selectedIds: new Set(),
}
case "SET_SELECTED": case "SET_SELECTED":
return { ...state, selectedIds: action.payload } return { ...state, selectedIds: action.payload }
case "TOGGLE_SELECTED": { case "TOGGLE_SELECTED": {
@ -69,86 +115,77 @@ function filesReducer(state: FilesState, action: FilesAction): FilesState {
sortDirection: action.payload.direction, sortDirection: action.payload.direction,
} }
case "SET_SEARCH": case "SET_SEARCH":
return { ...state, searchQuery: action.payload, selectedIds: new Set() }
case "STAR_FILE":
return { return {
...state, ...state,
files: state.files.map((f) => searchQuery: action.payload,
f.id === action.payload ? { ...f, starred: !f.starred } : f selectedIds: new Set(),
}
case "SET_FILES":
return { ...state, files: action.payload }
case "APPEND_FILES":
return {
...state,
files: [...state.files, ...action.payload],
}
case "SET_LOADING":
return { ...state, isLoading: action.payload }
case "SET_ERROR":
return { ...state, error: action.payload }
case "SET_CONNECTED":
return { ...state, isConnected: action.payload }
case "SET_STORAGE_QUOTA":
return { ...state, storageQuota: action.payload }
case "SET_NEXT_PAGE_TOKEN":
return { ...state, nextPageToken: action.payload }
case "OPTIMISTIC_STAR":
return {
...state,
files: state.files.map(f =>
f.id === action.payload
? { ...f, starred: !f.starred }
: f
), ),
} }
case "TRASH_FILE": case "OPTIMISTIC_TRASH":
return { return {
...state, ...state,
files: state.files.map((f) => files: state.files.filter(
f.id === action.payload ? { ...f, trashed: true } : f f => f.id !== action.payload
), ),
selectedIds: new Set(), selectedIds: new Set(),
} }
case "RESTORE_FILE": case "OPTIMISTIC_RESTORE":
return { return {
...state, ...state,
files: state.files.map((f) => files: state.files.filter(
f.id === action.payload ? { ...f, trashed: false } : f f => f.id !== action.payload
), ),
} }
case "RENAME_FILE": case "OPTIMISTIC_RENAME":
return { return {
...state, ...state,
files: state.files.map((f) => files: state.files.map(f =>
f.id === action.payload.id
? { ...f, name: action.payload.name, modifiedAt: new Date().toISOString() }
: f
),
}
case "CREATE_FOLDER": {
const newFolder: FileItem = {
id: `folder-${Date.now()}`,
name: action.payload.name,
type: "folder",
size: 0,
path: action.payload.path,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
owner: { name: "Martine Vogel" },
starred: false,
shared: false,
trashed: false,
parentId: action.payload.parentId,
}
return { ...state, files: [...state.files, newFolder] }
}
case "CREATE_FILE": {
const newFile: FileItem = {
id: `file-${Date.now()}`,
name: action.payload.name,
type: action.payload.fileType,
size: 0,
path: action.payload.path,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
owner: { name: "Martine Vogel" },
starred: false,
shared: false,
trashed: false,
parentId: action.payload.parentId,
}
return { ...state, files: [...state.files, newFile] }
}
case "MOVE_FILE":
return {
...state,
files: state.files.map((f) =>
f.id === action.payload.id f.id === action.payload.id
? { ? {
...f, ...f,
parentId: action.payload.targetFolderId, name: action.payload.name,
path: action.payload.targetPath,
modifiedAt: new Date().toISOString(), modifiedAt: new Date().toISOString(),
} }
: f : f
), ),
} }
case "OPTIMISTIC_ADD_FOLDER":
return {
...state,
files: [action.payload, ...state.files],
}
case "REMOVE_FILE":
return {
...state,
files: state.files.filter(
f => f.id !== action.payload
),
}
default: default:
return state return state
} }
@ -161,42 +198,440 @@ const initialState: FilesState = {
sortBy: "name", sortBy: "name",
sortDirection: "asc", sortDirection: "asc",
searchQuery: "", searchQuery: "",
files: mockFiles, files: [],
isConnected: null,
isLoading: true,
error: null,
storageQuota: mockStorageUsage,
nextPageToken: null,
} }
type FilesContextValue = { type FilesContextValue = {
state: FilesState state: FilesState
dispatch: React.Dispatch<FilesAction> dispatch: React.Dispatch<FilesAction>
// fetching
fetchFiles: (
folderId?: string,
view?: FileView
) => Promise<void>
loadMore: () => Promise<void>
// mutations
createFolder: (
name: string,
parentId?: string
) => Promise<boolean>
renameFile: (
fileId: string,
newName: string
) => Promise<boolean>
moveFile: (
fileId: string,
newParentId: string,
oldParentId: string
) => Promise<boolean>
trashFile: (fileId: string) => Promise<boolean>
restoreFile: (fileId: string) => Promise<boolean>
starFile: (fileId: string) => Promise<boolean>
getUploadUrl: (
fileName: string,
mimeType: string,
parentId?: string
) => Promise<string | null>
fetchFolders: (
parentId?: string
) => Promise<
ReadonlyArray<{ id: string; name: string }> | null
>
// backward compat for mock data mode
getFilesForPath: (path: string[]) => FileItem[] getFilesForPath: (path: string[]) => FileItem[]
getFilesForView: (view: FileView, path: string[]) => FileItem[] getFilesForView: (view: FileView, path: string[]) => FileItem[]
storageUsage: typeof mockStorageUsage storageUsage: StorageUsage
getFolders: () => FileItem[] getFolders: () => FileItem[]
} }
const FilesContext = createContext<FilesContextValue | null>(null) const FilesContext = createContext<FilesContextValue | null>(null)
export function FilesProvider({ children }: { children: ReactNode }) { export function FilesProvider({
children,
}: {
children: ReactNode
}) {
const [state, dispatch] = useReducer(filesReducer, initialState) const [state, dispatch] = useReducer(filesReducer, initialState)
const currentFolderRef = useRef<string | undefined>(undefined)
const currentViewRef = useRef<FileView>("my-files")
// check connection on mount
useEffect(() => {
let cancelled = false
async function check() {
try {
const status =
await getGoogleDriveConnectionStatus()
if (cancelled) return
dispatch({
type: "SET_CONNECTED",
payload: status.connected,
})
if (!status.connected) {
// fall back to mock data
dispatch({ type: "SET_FILES", payload: mockFiles })
dispatch({ type: "SET_LOADING", payload: false })
}
} catch {
if (cancelled) return
dispatch({ type: "SET_CONNECTED", payload: false })
dispatch({ type: "SET_FILES", payload: mockFiles })
dispatch({ type: "SET_LOADING", payload: false })
}
}
check()
return () => {
cancelled = true
}
}, [])
// fetch storage quota when connected
useEffect(() => {
if (state.isConnected !== true) return
let cancelled = false
async function fetchQuota() {
const result = await getDriveStorageQuota()
if (cancelled) return
if (result.success) {
dispatch({
type: "SET_STORAGE_QUOTA",
payload: {
used: result.used,
total: result.total,
},
})
}
}
fetchQuota()
return () => {
cancelled = true
}
}, [state.isConnected])
const fetchFiles = useCallback(
async (folderId?: string, view?: FileView) => {
if (state.isConnected !== true) return
currentFolderRef.current = folderId
currentViewRef.current = view ?? "my-files"
dispatch({ type: "SET_LOADING", payload: true })
dispatch({ type: "SET_ERROR", payload: null })
try {
let result
if (view && view !== "my-files") {
result = await listDriveFilesForView(view)
} else if (state.searchQuery) {
const searchResult = await searchDriveFiles(
state.searchQuery
)
if (searchResult.success) {
dispatch({
type: "SET_FILES",
payload: searchResult.files,
})
dispatch({
type: "SET_NEXT_PAGE_TOKEN",
payload: null,
})
} else {
dispatch({
type: "SET_ERROR",
payload: searchResult.error,
})
}
dispatch({ type: "SET_LOADING", payload: false })
return
} else {
result = await listDriveFiles(folderId)
}
if (result.success) {
dispatch({
type: "SET_FILES",
payload: sortFiles(
result.files,
state.sortBy,
state.sortDirection
),
})
dispatch({
type: "SET_NEXT_PAGE_TOKEN",
payload: result.nextPageToken,
})
} else {
dispatch({
type: "SET_ERROR",
payload: result.error,
})
}
} catch (err) {
dispatch({
type: "SET_ERROR",
payload:
err instanceof Error
? err.message
: "Failed to load files",
})
} finally {
dispatch({ type: "SET_LOADING", payload: false })
}
},
[
state.isConnected,
state.searchQuery,
state.sortBy,
state.sortDirection,
]
)
const loadMore = useCallback(async () => {
if (!state.nextPageToken || state.isConnected !== true)
return
dispatch({ type: "SET_LOADING", payload: true })
try {
const view = currentViewRef.current
let result
if (view !== "my-files") {
result = await listDriveFilesForView(
view,
state.nextPageToken
)
} else {
result = await listDriveFiles(
currentFolderRef.current,
state.nextPageToken
)
}
if (result.success) {
dispatch({ type: "APPEND_FILES", payload: result.files })
dispatch({
type: "SET_NEXT_PAGE_TOKEN",
payload: result.nextPageToken,
})
}
} finally {
dispatch({ type: "SET_LOADING", payload: false })
}
}, [state.nextPageToken, state.isConnected])
const createFolder = useCallback(
async (
name: string,
parentId?: string
): Promise<boolean> => {
if (state.isConnected !== true) return false
const result = await createDriveFolder(name, parentId)
if (result.success) {
dispatch({
type: "OPTIMISTIC_ADD_FOLDER",
payload: result.folder,
})
return true
}
return false
},
[state.isConnected]
)
const renameFile = useCallback(
async (
fileId: string,
newName: string
): Promise<boolean> => {
if (state.isConnected !== true) return false
dispatch({
type: "OPTIMISTIC_RENAME",
payload: { id: fileId, name: newName },
})
const result = await renameDriveFile(fileId, newName)
if (!result.success) {
// revert will happen on next fetch
return false
}
return true
},
[state.isConnected]
)
const moveFile = useCallback(
async (
fileId: string,
newParentId: string,
oldParentId: string
): Promise<boolean> => {
if (state.isConnected !== true) return false
dispatch({ type: "REMOVE_FILE", payload: fileId })
const result = await moveDriveFile(
fileId,
newParentId,
oldParentId
)
if (!result.success) {
// re-fetch to recover
await fetchFiles(
currentFolderRef.current,
currentViewRef.current
)
return false
}
return true
},
[state.isConnected, fetchFiles]
)
const trashFile = useCallback(
async (fileId: string): Promise<boolean> => {
if (state.isConnected !== true) return false
dispatch({ type: "OPTIMISTIC_TRASH", payload: fileId })
const result = await trashDriveFile(fileId)
if (!result.success) {
await fetchFiles(
currentFolderRef.current,
currentViewRef.current
)
return false
}
return true
},
[state.isConnected, fetchFiles]
)
const restoreFile = useCallback(
async (fileId: string): Promise<boolean> => {
if (state.isConnected !== true) return false
dispatch({
type: "OPTIMISTIC_RESTORE",
payload: fileId,
})
const result = await restoreDriveFile(fileId)
if (!result.success) {
await fetchFiles(
currentFolderRef.current,
currentViewRef.current
)
return false
}
return true
},
[state.isConnected, fetchFiles]
)
const starFile = useCallback(
async (fileId: string): Promise<boolean> => {
if (state.isConnected !== true) {
// mock mode: just toggle locally
dispatch({ type: "OPTIMISTIC_STAR", payload: fileId })
return true
}
dispatch({ type: "OPTIMISTIC_STAR", payload: fileId })
const result = await toggleStarFile(fileId)
if (!result.success) {
dispatch({ type: "OPTIMISTIC_STAR", payload: fileId })
return false
}
return true
},
[state.isConnected]
)
const getUploadUrl = useCallback(
async (
fileName: string,
mimeType: string,
parentId?: string
): Promise<string | null> => {
if (state.isConnected !== true) return null
const result = await getUploadSessionUrl(
fileName,
mimeType,
parentId
)
if (result.success) return result.uploadUrl
return null
},
[state.isConnected]
)
const fetchFolders = useCallback(
async (
parentId?: string
): Promise<
ReadonlyArray<{ id: string; name: string }> | null
> => {
if (state.isConnected !== true) return null
const result = await listDriveFolders(parentId)
if (result.success) return result.folders
return null
},
[state.isConnected]
)
// backward compat: mock data selectors
const getFilesForPath = useCallback( const getFilesForPath = useCallback(
(path: string[]) => { (path: string[]) => {
return state.files.filter((f) => { if (state.isConnected === true) return state.files
const allFiles =
state.files.length > 0 ? state.files : mockFiles
return allFiles.filter(f => {
if (f.trashed) return false if (f.trashed) return false
if (path.length === 0) return f.parentId === null if (path.length === 0) return f.parentId === null
const parentFolder = state.files.find( const parentFolder = allFiles.find(
(folder) => folder =>
folder.type === "folder" && folder.type === "folder" &&
folder.name === path[path.length - 1] && folder.name === path[path.length - 1] &&
JSON.stringify(folder.path) === JSON.stringify(path.slice(0, -1)) JSON.stringify(folder.path) ===
JSON.stringify(path.slice(0, -1))
) )
return parentFolder && f.parentId === parentFolder.id return parentFolder && f.parentId === parentFolder.id
}) })
}, },
[state.files] [state.files, state.isConnected]
) )
const getFilesForView = useCallback( const getFilesForView = useCallback(
(view: FileView, path: string[]) => { (view: FileView, path: string[]) => {
// when connected, files are already fetched for
// the right view
if (state.isConnected === true) {
let files = state.files
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase()
files = files.filter(f =>
f.name.toLowerCase().includes(q)
)
}
return sortFiles(
files,
state.sortBy,
state.sortDirection
)
}
// mock data mode
const allFiles =
state.files.length > 0 ? state.files : mockFiles
let files: FileItem[] let files: FileItem[]
switch (view) { switch (view) {
@ -204,21 +639,33 @@ export function FilesProvider({ children }: { children: ReactNode }) {
files = getFilesForPath(path) files = getFilesForPath(path)
break break
case "shared": case "shared":
files = state.files.filter((f) => !f.trashed && f.shared) files = allFiles.filter(
f => !f.trashed && f.shared
)
break break
case "recent": { case "recent": {
const cutoff = new Date() const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - 30) cutoff.setDate(cutoff.getDate() - 30)
files = state.files files = allFiles
.filter((f) => !f.trashed && new Date(f.modifiedAt) > cutoff) .filter(
.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()) f =>
!f.trashed &&
new Date(f.modifiedAt) > cutoff
)
.sort(
(a, b) =>
new Date(b.modifiedAt).getTime() -
new Date(a.modifiedAt).getTime()
)
break break
} }
case "starred": case "starred":
files = state.files.filter((f) => !f.trashed && f.starred) files = allFiles.filter(
f => !f.trashed && f.starred
)
break break
case "trash": case "trash":
files = state.files.filter((f) => f.trashed) files = allFiles.filter(f => f.trashed)
break break
default: default:
files = [] files = []
@ -226,16 +673,29 @@ export function FilesProvider({ children }: { children: ReactNode }) {
if (state.searchQuery) { if (state.searchQuery) {
const q = state.searchQuery.toLowerCase() const q = state.searchQuery.toLowerCase()
files = files.filter((f) => f.name.toLowerCase().includes(q)) files = files.filter(f =>
f.name.toLowerCase().includes(q)
)
} }
return sortFiles(files, state.sortBy, state.sortDirection) return sortFiles(files, state.sortBy, state.sortDirection)
}, },
[state.files, state.searchQuery, state.sortBy, state.sortDirection, getFilesForPath] [
state.files,
state.searchQuery,
state.sortBy,
state.sortDirection,
state.isConnected,
getFilesForPath,
]
) )
const getFolders = useCallback(() => { const getFolders = useCallback(() => {
return state.files.filter((f) => f.type === "folder" && !f.trashed) const allFiles =
state.files.length > 0 ? state.files : mockFiles
return allFiles.filter(
f => f.type === "folder" && !f.trashed
)
}, [state.files]) }, [state.files])
return ( return (
@ -243,9 +703,19 @@ export function FilesProvider({ children }: { children: ReactNode }) {
value={{ value={{
state, state,
dispatch, dispatch,
fetchFiles,
loadMore,
createFolder,
renameFile,
moveFile,
trashFile,
restoreFile,
starFile,
getUploadUrl,
fetchFolders,
getFilesForPath, getFilesForPath,
getFilesForView, getFilesForView,
storageUsage: mockStorageUsage, storageUsage: state.storageQuota,
getFolders, getFolders,
}} }}
> >
@ -256,7 +726,10 @@ export function FilesProvider({ children }: { children: ReactNode }) {
export function useFiles() { export function useFiles() {
const ctx = useContext(FilesContext) const ctx = useContext(FilesContext)
if (!ctx) throw new Error("useFiles must be used within FilesProvider") if (!ctx)
throw new Error(
"useFiles must be used within FilesProvider"
)
return ctx return ctx
} }
@ -266,7 +739,6 @@ function sortFiles(
direction: SortDirection direction: SortDirection
): FileItem[] { ): FileItem[] {
const sorted = [...files].sort((a, b) => { const sorted = [...files].sort((a, b) => {
// folders always first
if (a.type === "folder" && b.type !== "folder") return -1 if (a.type === "folder" && b.type !== "folder") return -1
if (a.type !== "folder" && b.type === "folder") return 1 if (a.type !== "folder" && b.type === "folder") return 1
@ -276,7 +748,9 @@ function sortFiles(
cmp = a.name.localeCompare(b.name) cmp = a.name.localeCompare(b.name)
break break
case "modified": case "modified":
cmp = new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime() cmp =
new Date(a.modifiedAt).getTime() -
new Date(b.modifiedAt).getTime()
break break
case "size": case "size":
cmp = a.size - b.size cmp = a.size - b.size

View File

@ -13,6 +13,7 @@ export type AuthUser = {
readonly displayName: string | null readonly displayName: string | null
readonly avatarUrl: string | null readonly avatarUrl: string | null
readonly role: string readonly role: string
readonly googleEmail: string | null
readonly isActive: boolean readonly isActive: boolean
readonly lastLoginAt: string | null readonly lastLoginAt: string | null
readonly createdAt: string readonly createdAt: string
@ -63,6 +64,7 @@ export async function getCurrentUser(): Promise<AuthUser | null> {
displayName: "Dev User", displayName: "Dev User",
avatarUrl: null, avatarUrl: null,
role: "admin", role: "admin",
googleEmail: null,
isActive: true, isActive: true,
lastLoginAt: new Date().toISOString(), lastLoginAt: new Date().toISOString(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@ -108,6 +110,7 @@ export async function getCurrentUser(): Promise<AuthUser | null> {
displayName: dbUser.displayName, displayName: dbUser.displayName,
avatarUrl: dbUser.avatarUrl, avatarUrl: dbUser.avatarUrl,
role: dbUser.role, role: dbUser.role,
googleEmail: dbUser.googleEmail ?? null,
isActive: dbUser.isActive, isActive: dbUser.isActive,
lastLoginAt: now, lastLoginAt: now,
createdAt: dbUser.createdAt, createdAt: dbUser.createdAt,

76
src/lib/crypto.ts Executable file
View File

@ -0,0 +1,76 @@
// shared AES-256-GCM encryption for secrets at rest in D1.
// uses Web Crypto API (available in Cloudflare Workers).
const ALGORITHM = "AES-GCM"
const KEY_LENGTH = 256
const IV_LENGTH = 12
const TAG_LENGTH = 128
async function deriveKey(
secret: string,
salt: string
): Promise<CryptoKey> {
const encoder = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "PBKDF2" },
false,
["deriveKey"]
)
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: encoder.encode(salt),
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: ALGORITHM, length: KEY_LENGTH },
false,
["encrypt", "decrypt"]
)
}
export async function encrypt(
plaintext: string,
secret: string,
salt: string
): Promise<string> {
const key = await deriveKey(secret, salt)
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
const encoder = new TextEncoder()
const ciphertext = await crypto.subtle.encrypt(
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
key,
encoder.encode(plaintext)
)
const packed = new Uint8Array(iv.length + ciphertext.byteLength)
packed.set(iv)
packed.set(new Uint8Array(ciphertext), iv.length)
return btoa(String.fromCharCode(...packed))
}
export async function decrypt(
encoded: string,
secret: string,
salt: string
): Promise<string> {
const key = await deriveKey(secret, salt)
const packed = Uint8Array.from(atob(encoded), c => c.charCodeAt(0))
const iv = packed.slice(0, IV_LENGTH)
const ciphertext = packed.slice(IV_LENGTH)
const plaintext = await crypto.subtle.decrypt(
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
key,
ciphertext
)
return new TextDecoder().decode(plaintext)
}

View File

@ -38,6 +38,7 @@ export type FileItem = {
sharedWith?: SharedUser[] sharedWith?: SharedUser[]
trashed: boolean trashed: boolean
parentId: string | null parentId: string | null
webViewLink?: string
} }
export type StorageUsage = { export type StorageUsage = {

View File

@ -0,0 +1,126 @@
// JWT-based auth for google service accounts with
// domain-wide delegation (impersonating workspace users).
// uses Web Crypto API for RS256 signing (cloudflare workers compatible).
import {
GOOGLE_DRIVE_SCOPES,
GOOGLE_TOKEN_URL,
type ServiceAccountKey,
} from "../config"
function base64url(data: Uint8Array): string {
return btoa(String.fromCharCode(...data))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
}
function base64urlEncode(str: string): string {
return base64url(new TextEncoder().encode(str))
}
// convert PEM private key to CryptoKey for RS256 signing
async function importPrivateKey(
pem: string
): Promise<CryptoKey> {
const pemBody = pem
.replace(/-----BEGIN PRIVATE KEY-----/g, "")
.replace(/-----END PRIVATE KEY-----/g, "")
.replace(/\s/g, "")
const binaryString = atob(pemBody)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return crypto.subtle.importKey(
"pkcs8",
bytes.buffer,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"]
)
}
export async function createServiceAccountJWT(
serviceAccountKey: ServiceAccountKey,
userEmail: string,
scopes: ReadonlyArray<string> = GOOGLE_DRIVE_SCOPES
): Promise<string> {
const now = Math.floor(Date.now() / 1000)
const header = {
alg: "RS256",
typ: "JWT",
kid: serviceAccountKey.private_key_id,
}
const payload = {
iss: serviceAccountKey.client_email,
sub: userEmail,
scope: scopes.join(" "),
aud: GOOGLE_TOKEN_URL,
iat: now,
exp: now + 3600,
}
const headerB64 = base64urlEncode(JSON.stringify(header))
const payloadB64 = base64urlEncode(JSON.stringify(payload))
const signingInput = `${headerB64}.${payloadB64}`
const key = await importPrivateKey(
serviceAccountKey.private_key
)
const signature = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
key,
new TextEncoder().encode(signingInput)
)
const signatureB64 = base64url(new Uint8Array(signature))
return `${signingInput}.${signatureB64}`
}
export type AccessTokenResponse = {
readonly access_token: string
readonly token_type: string
readonly expires_in: number
}
export async function exchangeJWTForAccessToken(
jwt: string
): Promise<AccessTokenResponse> {
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: jwt,
}),
})
if (!response.ok) {
const body = await response.text()
throw new Error(
`Token exchange failed (${response.status}): ${body}`
)
}
return response.json() as Promise<AccessTokenResponse>
}
export async function getAccessToken(
serviceAccountKey: ServiceAccountKey,
userEmail: string
): Promise<string> {
const jwt = await createServiceAccountJWT(
serviceAccountKey,
userEmail
)
const tokenResponse = await exchangeJWTForAccessToken(jwt)
return tokenResponse.access_token
}

View File

@ -0,0 +1,42 @@
// in-memory token cache keyed by user email.
// NOTE: in cloudflare workers, this resets per request since
// each request runs in its own isolate. for now we just
// generate tokens per-request (they're fast ~100ms).
// if perf becomes an issue, swap to KV-backed cache.
type CachedToken = {
readonly accessToken: string
readonly expiresAt: number
}
const TOKEN_BUFFER_MS = 5 * 60 * 1000 // refresh 5 min early
const cache = new Map<string, CachedToken>()
export function getCachedToken(
userEmail: string
): string | null {
const entry = cache.get(userEmail)
if (!entry) return null
if (Date.now() >= entry.expiresAt) {
cache.delete(userEmail)
return null
}
return entry.accessToken
}
export function setCachedToken(
userEmail: string,
accessToken: string,
expiresInSeconds: number
): void {
cache.set(userEmail, {
accessToken,
expiresAt:
Date.now() + expiresInSeconds * 1000 - TOKEN_BUFFER_MS,
})
}
export function clearCachedToken(userEmail: string): void {
cache.delete(userEmail)
}

View File

@ -0,0 +1,427 @@
// google drive REST API v3 wrapper.
// each method accepts userEmail for domain-wide delegation impersonation.
import {
GOOGLE_DRIVE_API,
GOOGLE_UPLOAD_API,
type ServiceAccountKey,
} from "../config"
import {
getAccessToken,
} from "../auth/service-account"
import {
getCachedToken,
setCachedToken,
clearCachedToken,
} from "../auth/token-cache"
import { ConcurrencyLimiter } from "@/lib/netsuite/rate-limiter/concurrency-limiter"
import {
DRIVE_FILE_FIELDS,
DRIVE_LIST_FIELDS,
type DriveFile,
type DriveFileList,
type DriveAbout,
type DriveSharedDriveList,
type ListFilesOptions,
type UploadOptions,
} from "./types"
const MAX_RETRIES = 3
const INITIAL_BACKOFF_MS = 1000
type DriveClientConfig = {
readonly serviceAccountKey: ServiceAccountKey
readonly limiter?: ConcurrencyLimiter
}
export class DriveClient {
private serviceAccountKey: ServiceAccountKey
private limiter: ConcurrencyLimiter
constructor(config: DriveClientConfig) {
this.serviceAccountKey = config.serviceAccountKey
this.limiter = config.limiter ?? new ConcurrencyLimiter(10)
}
private async getToken(userEmail: string): Promise<string> {
const cached = getCachedToken(userEmail)
if (cached) return cached
const token = await getAccessToken(
this.serviceAccountKey,
userEmail
)
setCachedToken(userEmail, token, 3600)
return token
}
private async request<T>(
userEmail: string,
path: string,
options: RequestInit = {},
isUpload = false
): Promise<T> {
return this.limiter.execute(async () => {
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const token = await this.getToken(userEmail)
const baseUrl = isUpload
? GOOGLE_UPLOAD_API
: GOOGLE_DRIVE_API
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
})
if (response.ok) {
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
// refresh token on 401
if (response.status === 401) {
clearCachedToken(userEmail)
continue
}
// retry on rate limit or server error
if (
response.status === 429 ||
response.status >= 500
) {
if (response.status === 429) {
this.limiter.reduceConcurrency()
}
const backoff =
INITIAL_BACKOFF_MS * Math.pow(2, attempt)
await new Promise(r => setTimeout(r, backoff))
lastError = new Error(
`Google API ${response.status}: ${await response.text()}`
)
continue
}
// non-retryable error
const body = await response.text()
throw new Error(
`Google Drive API error (${response.status}): ${body}`
)
}
throw lastError ?? new Error("Max retries exceeded")
})
}
async listFiles(
userEmail: string,
options: ListFilesOptions = {}
): Promise<DriveFileList> {
const params = new URLSearchParams({
fields: DRIVE_LIST_FIELDS,
pageSize: String(options.pageSize ?? 100),
})
const queryParts: string[] = []
if (options.folderId) {
queryParts.push(`'${options.folderId}' in parents`)
}
if (options.trashed !== undefined) {
queryParts.push(`trashed = ${options.trashed}`)
} else {
queryParts.push("trashed = false")
}
if (options.sharedWithMe) {
queryParts.push("sharedWithMe = true")
}
if (options.query) {
queryParts.push(options.query)
}
if (queryParts.length > 0) {
params.set("q", queryParts.join(" and "))
}
if (options.orderBy) {
params.set("orderBy", options.orderBy)
}
if (options.pageToken) {
params.set("pageToken", options.pageToken)
}
if (options.driveId) {
params.set("driveId", options.driveId)
params.set("corpora", "drive")
params.set("includeItemsFromAllDrives", "true")
params.set("supportsAllDrives", "true")
}
return this.request<DriveFileList>(
userEmail,
`/files?${params.toString()}`
)
}
async getFile(
userEmail: string,
fileId: string
): Promise<DriveFile> {
const params = new URLSearchParams({
fields: DRIVE_FILE_FIELDS,
supportsAllDrives: "true",
})
return this.request<DriveFile>(
userEmail,
`/files/${fileId}?${params.toString()}`
)
}
async createFolder(
userEmail: string,
options: {
readonly name: string
readonly parentId?: string
readonly driveId?: string
}
): Promise<DriveFile> {
const metadata: Record<string, unknown> = {
name: options.name,
mimeType: "application/vnd.google-apps.folder",
}
if (options.parentId) {
metadata.parents = [options.parentId]
} else if (options.driveId) {
metadata.parents = [options.driveId]
}
const params = new URLSearchParams({
fields: DRIVE_FILE_FIELDS,
supportsAllDrives: "true",
})
return this.request<DriveFile>(
userEmail,
`/files?${params.toString()}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(metadata),
}
)
}
async initiateResumableUpload(
userEmail: string,
options: UploadOptions
): Promise<string> {
// returns the resumable upload session URI
return this.limiter.execute(async () => {
const token = await this.getToken(userEmail)
const metadata: Record<string, unknown> = {
name: options.name,
mimeType: options.mimeType,
}
if (options.parentId) {
metadata.parents = [options.parentId]
} else if (options.driveId) {
metadata.parents = [options.driveId]
}
const params = new URLSearchParams({
uploadType: "resumable",
supportsAllDrives: "true",
fields: DRIVE_FILE_FIELDS,
})
const response = await fetch(
`${GOOGLE_UPLOAD_API}/files?${params.toString()}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Upload-Content-Type": options.mimeType,
},
body: JSON.stringify(metadata),
}
)
if (!response.ok) {
const body = await response.text()
throw new Error(
`Failed to initiate upload (${response.status}): ${body}`
)
}
const location = response.headers.get("Location")
if (!location) {
throw new Error("No upload URI in response")
}
return location
})
}
async downloadFile(
userEmail: string,
fileId: string
): Promise<Response> {
const token = await this.getToken(userEmail)
const params = new URLSearchParams({
alt: "media",
supportsAllDrives: "true",
})
return fetch(
`${GOOGLE_DRIVE_API}/files/${fileId}?${params.toString()}`,
{
headers: { Authorization: `Bearer ${token}` },
}
)
}
async exportFile(
userEmail: string,
fileId: string,
exportMimeType: string
): Promise<Response> {
const token = await this.getToken(userEmail)
const params = new URLSearchParams({
mimeType: exportMimeType,
})
return fetch(
`${GOOGLE_DRIVE_API}/files/${fileId}/export?${params.toString()}`,
{
headers: { Authorization: `Bearer ${token}` },
}
)
}
async renameFile(
userEmail: string,
fileId: string,
newName: string
): Promise<DriveFile> {
const params = new URLSearchParams({
fields: DRIVE_FILE_FIELDS,
supportsAllDrives: "true",
})
return this.request<DriveFile>(
userEmail,
`/files/${fileId}?${params.toString()}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName }),
}
)
}
async moveFile(
userEmail: string,
fileId: string,
newParentId: string,
oldParentId: string
): Promise<DriveFile> {
const params = new URLSearchParams({
addParents: newParentId,
removeParents: oldParentId,
fields: DRIVE_FILE_FIELDS,
supportsAllDrives: "true",
})
return this.request<DriveFile>(
userEmail,
`/files/${fileId}?${params.toString()}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
}
)
}
async trashFile(
userEmail: string,
fileId: string
): Promise<DriveFile> {
const params = new URLSearchParams({
fields: DRIVE_FILE_FIELDS,
supportsAllDrives: "true",
})
return this.request<DriveFile>(
userEmail,
`/files/${fileId}?${params.toString()}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trashed: true }),
}
)
}
async restoreFile(
userEmail: string,
fileId: string
): Promise<DriveFile> {
const params = new URLSearchParams({
fields: DRIVE_FILE_FIELDS,
supportsAllDrives: "true",
})
return this.request<DriveFile>(
userEmail,
`/files/${fileId}?${params.toString()}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trashed: false }),
}
)
}
async getStorageQuota(
userEmail: string
): Promise<DriveAbout> {
return this.request<DriveAbout>(
userEmail,
"/about?fields=storageQuota,user"
)
}
async searchFiles(
userEmail: string,
searchQuery: string,
pageSize = 50,
driveId?: string
): Promise<DriveFileList> {
return this.listFiles(userEmail, {
query: `fullText contains '${searchQuery.replace(/'/g, "\\'")}'`,
pageSize,
driveId,
})
}
async listSharedDrives(
userEmail: string
): Promise<DriveSharedDriveList> {
return this.request<DriveSharedDriveList>(
userEmail,
"/drives?pageSize=100"
)
}
}

89
src/lib/google/client/types.ts Executable file
View File

@ -0,0 +1,89 @@
// google drive API v3 response types
export type DriveUser = {
readonly displayName: string
readonly photoLink?: string
readonly emailAddress?: string
}
export type DrivePermission = {
readonly id: string
readonly type: string
readonly role: string
readonly emailAddress?: string
readonly displayName?: string
readonly photoLink?: string
}
export type DriveFile = {
readonly id: string
readonly name: string
readonly mimeType: string
readonly size?: string
readonly createdTime?: string
readonly modifiedTime?: string
readonly owners?: ReadonlyArray<DriveUser>
readonly parents?: ReadonlyArray<string>
readonly permissions?: ReadonlyArray<DrivePermission>
readonly shared?: boolean
readonly trashed?: boolean
readonly webViewLink?: string
readonly webContentLink?: string
readonly iconLink?: string
readonly thumbnailLink?: string
readonly driveId?: string
}
export type DriveFileList = {
readonly files: ReadonlyArray<DriveFile>
readonly nextPageToken?: string
readonly incompleteSearch?: boolean
}
export type DriveAbout = {
readonly storageQuota: {
readonly limit?: string
readonly usage: string
readonly usageInDrive: string
readonly usageInDriveTrash: string
}
readonly user: DriveUser
}
export type DriveSharedDrive = {
readonly id: string
readonly name: string
readonly createdTime?: string
}
export type DriveSharedDriveList = {
readonly drives: ReadonlyArray<DriveSharedDrive>
readonly nextPageToken?: string
}
export type ListFilesOptions = {
readonly folderId?: string
readonly query?: string
readonly pageSize?: number
readonly pageToken?: string
readonly orderBy?: string
readonly driveId?: string
readonly trashed?: boolean
readonly sharedWithMe?: boolean
}
export type UploadOptions = {
readonly name: string
readonly parentId?: string
readonly mimeType: string
readonly driveId?: string
}
// fields we always request from the API
export const DRIVE_FILE_FIELDS =
"id,name,mimeType,size,createdTime,modifiedTime,owners," +
"parents,permissions,shared,trashed,webViewLink," +
"webContentLink,iconLink,thumbnailLink,driveId"
export const DRIVE_LIST_FIELDS =
`nextPageToken,files(${DRIVE_FILE_FIELDS})`

65
src/lib/google/config.ts Executable file
View File

@ -0,0 +1,65 @@
export type GoogleConfig = {
readonly encryptionKey: string
}
export function getGoogleConfig(
env: Record<string, string | undefined>
): GoogleConfig {
const encryptionKey = env.GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY
if (!encryptionKey) {
throw new Error(
"GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY not configured"
)
}
return { encryptionKey }
}
export type ServiceAccountKey = {
readonly type: string
readonly project_id: string
readonly private_key_id: string
readonly private_key: string
readonly client_email: string
readonly client_id: string
readonly auth_uri: string
readonly token_uri: string
readonly auth_provider_x509_cert_url: string
readonly client_x509_cert_url: string
readonly universe_domain: string
}
export function parseServiceAccountKey(
json: string
): ServiceAccountKey {
const parsed: unknown = JSON.parse(json)
if (
typeof parsed !== "object" ||
parsed === null ||
!("type" in parsed) ||
!("private_key" in parsed) ||
!("client_email" in parsed)
) {
throw new Error("Invalid service account key JSON")
}
return parsed as ServiceAccountKey
}
export const GOOGLE_DRIVE_SCOPES = [
"https://www.googleapis.com/auth/drive",
] as const
export const GOOGLE_TOKEN_URL =
"https://oauth2.googleapis.com/token"
export const GOOGLE_DRIVE_API =
"https://www.googleapis.com/drive/v3"
export const GOOGLE_UPLOAD_API =
"https://www.googleapis.com/upload/drive/v3"
const GOOGLE_CRYPTO_SALT = "compass-google-service-account"
export function getGoogleCryptoSalt(): string {
return GOOGLE_CRYPTO_SALT
}

135
src/lib/google/mapper.ts Executable file
View File

@ -0,0 +1,135 @@
// maps google drive API responses to our FileItem type
import type { FileItem, FileType, SharedUser, SharedRole } from "@/lib/files-data"
import type { DriveFile } from "./client/types"
const GOOGLE_APPS_FOLDER = "application/vnd.google-apps.folder"
const GOOGLE_APPS_DOCUMENT = "application/vnd.google-apps.document"
const GOOGLE_APPS_SPREADSHEET =
"application/vnd.google-apps.spreadsheet"
const GOOGLE_APPS_PRESENTATION =
"application/vnd.google-apps.presentation"
function mimeTypeToFileType(mimeType: string): FileType {
if (mimeType === GOOGLE_APPS_FOLDER) return "folder"
if (mimeType === GOOGLE_APPS_DOCUMENT) return "document"
if (mimeType === GOOGLE_APPS_SPREADSHEET)
return "spreadsheet"
if (mimeType === GOOGLE_APPS_PRESENTATION)
return "document"
if (mimeType === "application/pdf") return "pdf"
if (mimeType.startsWith("image/")) return "image"
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("audio/")) return "audio"
if (
mimeType.includes("zip") ||
mimeType.includes("compressed") ||
mimeType.includes("archive") ||
mimeType.includes("tar") ||
mimeType.includes("gzip")
)
return "archive"
if (
mimeType.includes("spreadsheet") ||
mimeType.includes("excel") ||
mimeType === "text/csv"
)
return "spreadsheet"
if (
mimeType.includes("document") ||
mimeType.includes("word") ||
mimeType === "text/plain" ||
mimeType === "text/rtf"
)
return "document"
if (
mimeType.includes("javascript") ||
mimeType.includes("json") ||
mimeType.includes("xml") ||
mimeType.includes("html") ||
mimeType.includes("css") ||
mimeType.includes("typescript")
)
return "code"
return "unknown"
}
function mapPermissionRole(role: string): SharedRole {
if (role === "writer" || role === "owner") return "editor"
return "viewer"
}
export function mapDriveFileToFileItem(
driveFile: DriveFile,
starredIds: ReadonlySet<string>,
parentId: string | null = null
): FileItem {
const owner = driveFile.owners?.[0]
const sharedWith: SharedUser[] = (
driveFile.permissions ?? []
)
.filter(p => p.role !== "owner" && p.type === "user")
.map(p => ({
name: p.displayName ?? p.emailAddress ?? "Unknown",
avatar: p.photoLink,
role: mapPermissionRole(p.role),
}))
return {
id: driveFile.id,
name: driveFile.name,
type: mimeTypeToFileType(driveFile.mimeType),
mimeType: driveFile.mimeType,
size: driveFile.size ? Number(driveFile.size) : 0,
path: [],
createdAt: driveFile.createdTime ?? new Date().toISOString(),
modifiedAt:
driveFile.modifiedTime ?? new Date().toISOString(),
owner: {
name: owner?.displayName ?? "Unknown",
avatar: owner?.photoLink,
},
starred: starredIds.has(driveFile.id),
shared: sharedWith.length > 0 || driveFile.shared === true,
sharedWith:
sharedWith.length > 0 ? sharedWith : undefined,
trashed: driveFile.trashed ?? false,
parentId,
webViewLink: driveFile.webViewLink,
}
}
// export types for google-native files
export function isGoogleNativeFile(mimeType: string): boolean {
return mimeType.startsWith("application/vnd.google-apps.")
}
export function getExportMimeType(
googleMimeType: string
): string | null {
switch (googleMimeType) {
case GOOGLE_APPS_DOCUMENT:
return "application/pdf"
case GOOGLE_APPS_SPREADSHEET:
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case GOOGLE_APPS_PRESENTATION:
return "application/pdf"
default:
return null
}
}
export function getExportExtension(
googleMimeType: string
): string {
switch (googleMimeType) {
case GOOGLE_APPS_DOCUMENT:
return ".pdf"
case GOOGLE_APPS_SPREADSHEET:
return ".xlsx"
case GOOGLE_APPS_PRESENTATION:
return ".pdf"
default:
return ""
}
}

View File

@ -1,76 +1,23 @@
// AES-256-GCM encryption for OAuth tokens at rest in D1. // netsuite-specific encrypt/decrypt that delegates to shared crypto
// uses Web Crypto API (available in Cloudflare Workers). // with the netsuite-specific PBKDF2 salt.
const ALGORITHM = "AES-GCM" import {
const KEY_LENGTH = 256 encrypt as sharedEncrypt,
const IV_LENGTH = 12 decrypt as sharedDecrypt,
const TAG_LENGTH = 128 } from "@/lib/crypto"
async function deriveKey(secret: string): Promise<CryptoKey> { const NETSUITE_SALT = "compass-netsuite-tokens"
const encoder = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "PBKDF2" },
false,
["deriveKey"]
)
// static salt is fine here - the encryption key itself
// is the secret, and each ciphertext gets a unique IV
const salt = encoder.encode("compass-netsuite-tokens")
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: ALGORITHM, length: KEY_LENGTH },
false,
["encrypt", "decrypt"]
)
}
export async function encrypt( export async function encrypt(
plaintext: string, plaintext: string,
secret: string secret: string
): Promise<string> { ): Promise<string> {
const key = await deriveKey(secret) return sharedEncrypt(plaintext, secret, NETSUITE_SALT)
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
const encoder = new TextEncoder()
const ciphertext = await crypto.subtle.encrypt(
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
key,
encoder.encode(plaintext)
)
// pack as iv:ciphertext in base64
const packed = new Uint8Array(iv.length + ciphertext.byteLength)
packed.set(iv)
packed.set(new Uint8Array(ciphertext), iv.length)
return btoa(String.fromCharCode(...packed))
} }
export async function decrypt( export async function decrypt(
encoded: string, encoded: string,
secret: string secret: string
): Promise<string> { ): Promise<string> {
const key = await deriveKey(secret) return sharedDecrypt(encoded, secret, NETSUITE_SALT)
const packed = Uint8Array.from(atob(encoded), c => c.charCodeAt(0))
const iv = packed.slice(0, IV_LENGTH)
const ciphertext = packed.slice(IV_LENGTH)
const plaintext = await crypto.subtle.decrypt(
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
key,
ciphertext
)
return new TextDecoder().decode(plaintext)
} }

View File

@ -17,7 +17,8 @@ function isPublicPath(pathname: string): boolean {
return ( return (
publicPaths.includes(pathname) || publicPaths.includes(pathname) ||
pathname.startsWith("/api/auth/") || pathname.startsWith("/api/auth/") ||
pathname.startsWith("/api/netsuite/") pathname.startsWith("/api/netsuite/") ||
pathname.startsWith("/api/google/")
) )
} }