From 04eb8727ebe9fa2a3b5b0570788245af3b92d1a8 Mon Sep 17 00:00:00 2001 From: Huxley Date: Wed, 15 Jul 2026 20:16:11 +0800 Subject: [PATCH] feat(web): add authenticated file workflow - feat: connect login and logout to the REST API with session-scoped tokens, protected routing, single-flight refresh, and authenticated query cache cleanup. - feat: add the Ant Design application shell with root file listing, pagination, small-file upload, and authenticated download. - test: cover session storage and API retry and error behavior with Vitest. - docs: document the browser milestone, same-origin API topology, and deferred transfer and account features. --- README.md | 11 +- docs/web/decisions.md | 26 ++ docs/web/roadmap.md | 20 +- web/README.md | 37 ++- web/package-lock.json | 379 +++++++++++++++++++++++- web/package.json | 7 +- web/src/api/auth.ts | 19 ++ web/src/api/client.test.ts | 127 ++++++++ web/src/api/client.ts | 159 ++++++++++ web/src/api/files.ts | 27 ++ web/src/api/session.test.ts | 60 ++++ web/src/api/session.ts | 87 ++++++ web/src/api/types.ts | 28 ++ web/src/app/RequireAuth.tsx | 15 + web/src/app/providers.tsx | 56 +++- web/src/app/router.tsx | 23 +- web/src/components/AppShell.tsx | 86 ++++++ web/src/features/auth/useAuthSession.ts | 11 + web/src/index.css | 3 +- web/src/pages/FilesPage.tsx | 241 +++++++++++++++ web/src/pages/HomePage.tsx | 19 -- web/src/pages/LoginPage.tsx | 113 +++++++ web/vite.config.ts | 5 + web/vitest.config.ts | 7 + 24 files changed, 1524 insertions(+), 42 deletions(-) create mode 100644 web/src/api/auth.ts create mode 100644 web/src/api/client.test.ts create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/files.ts create mode 100644 web/src/api/session.test.ts create mode 100644 web/src/api/session.ts create mode 100644 web/src/api/types.ts create mode 100644 web/src/app/RequireAuth.tsx create mode 100644 web/src/components/AppShell.tsx create mode 100644 web/src/features/auth/useAuthSession.ts create mode 100644 web/src/pages/FilesPage.tsx delete mode 100644 web/src/pages/HomePage.tsx create mode 100644 web/src/pages/LoginPage.tsx create mode 100644 web/vitest.config.ts diff --git a/README.md b/README.md index ab514f6..abbd1e0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ MyGO is a WebDisk (cloud drive) server, written in Go. -**Current status**: pre-alpha — project skeleton with no core functionality implemented yet. +**Current status**: pre-alpha — basic JWT authentication and file transfer flows +are available while their APIs and large-file behavior continue to evolve. --- @@ -11,8 +12,8 @@ MyGO is a WebDisk (cloud drive) server, written in Go. ### v0 - [ ] CLI configuration management (`mygo config`) -- [ ] User authentication (JWT) -- [ ] File upload / download / management (HTTP API) +- [x] User authentication (JWT) +- [x] Basic file upload / download / management (HTTP API) - [ ] Admin endpoints - [ ] WebDAV support @@ -39,7 +40,9 @@ MyGO is a WebDisk (cloud drive) server, written in Go. ## Frontend -The frontend uses React + TypeScript and communicates with the backend via HTTP API. Frontend source is maintained in `web/`. +The frontend uses React + TypeScript and communicates with the backend via HTTP +API. Its first milestone supports login, a root file list, single-file upload, +and authenticated download. Frontend source is maintained in `web/`. --- diff --git a/docs/web/decisions.md b/docs/web/decisions.md index 1be9098..9be6f2e 100644 --- a/docs/web/decisions.md +++ b/docs/web/decisions.md @@ -17,3 +17,29 @@ - The Web and future native clients consume the same client-neutral API contracts. - MyGO or a reverse proxy may host `web/dist` with an SPA fallback without changing the rendering model. - MyGO domain components own file-browser behavior and must not depend on Ant Design request behavior for business logic. + +## 2026-07-14: Browser Authentication and Root File Workflow + +**Context**: The first Web milestone needs to exercise the existing login, file +list, upload, and download APIs without committing to the later directory, +account, admin, or large-transfer designs. + +**Decisions**: + +| Area | Choice | Guidance | +|------|--------|----------| +| API topology | Same-origin `/api/v1` | Vite proxies `/api` to the local Go server. Production uses a same-origin reverse proxy; the milestone does not add CORS or Go static hosting. | +| Browser session | Token pair in `sessionStorage` | Reloading the tab preserves the session, while closing the tab clears it. Do not add persistent login until the token transport design is revisited. | +| Token refresh | Refresh once after a protected request returns 401 | Share one in-flight refresh across concurrent failures, store the rotated pair, and retry each request once. Clear the session if refresh or the retry fails. | +| File scope | Root directory only | List root entries, upload one file to root, and download files. Show directories as non-interactive rows. | +| Transfer model | Browser `FormData` upload and authenticated Blob download | This is intentionally limited to the small-file milestone; progress, streaming-to-disk, chunking, resume, and queues remain deferred. | + +**Consequences**: +- The API client owns bearer headers, error parsing, refresh coordination, and + session invalidation; pages consume operation-specific functions. +- TanStack Query caches are cleared whenever an authenticated session ends so + one user cannot see another user's cached file metadata. +- The file picker is independent of Ant Design upload request behavior, keeping + transfer policy in MyGO code. +- Handwritten TypeScript wire types remain temporary until an OpenAPI contract + is available. diff --git a/docs/web/roadmap.md b/docs/web/roadmap.md index b3665a9..21dd887 100644 --- a/docs/web/roadmap.md +++ b/docs/web/roadmap.md @@ -8,9 +8,23 @@ - Production may serve `web/dist` from MyGO or a reverse proxy. - Shared API contracts should remain client-neutral and eventually be described by OpenAPI. +## Current Milestone + +The browser client now provides the first authenticated file workflow: + +- Email/password login through the shared REST API +- Session-scoped access and refresh tokens with one automatic refresh retry +- Protected application shell and root file list +- Single-file upload to the root directory and authenticated download +- Server-side pagination with 50 items per page + +Directory navigation and management, multi-file queues, transfer progress, +resumable or chunked transfers, account/profile/settings screens, and admin +screens remain deferred. + ## Foundation -The initial project contains only the framework and styling foundation: +The project foundation contains: - Node.js 24 and npm - Vite @@ -19,7 +33,9 @@ The initial project contains only the framework and styling foundation: - TanStack Query provider - Tailwind CSS 4 through its Vite plugin - Ant Design provider and components +- Ant Design icons for application and file actions - Oxlint from the Vite template +- Vitest for framework-independent client and session tests Ant Design owns reusable UI components and theme tokens. Tailwind CSS is initially limited to application layout, spacing, and responsive utilities. @@ -40,10 +56,8 @@ web/src/ Some dependencies are suggested for future implementation. Refer these only when the corresponding feature is implemented and propose better choices if any: -- `@ant-design/icons`: Add Ant Design-consistent application and action icons when real screens require them. - `openapi-typescript`: Generate TypeScript API types from the shared OpenAPI document. - `openapi-fetch`: Provide a small type-safe Fetch client based on generated OpenAPI types. - `openapi-react-query`: Connect generated OpenAPI operations to TanStack Query if handwritten query adapters become repetitive. - `zustand`: Manage a cross-route upload queue, bulk selection, or other complex client-only state if React state is insufficient. -- `vitest`: Run unit and integration tests using the Vite toolchain. - `pdfjs-dist`: Preview PDF files in the browser when document preview is implemented. diff --git a/web/README.md b/web/README.md index 1514b1e..9992bd3 100644 --- a/web/README.md +++ b/web/README.md @@ -4,15 +4,44 @@ MyGO's browser client is a pure client-side rendered application built with Reac ## Development +Install the repository-pinned Go and Node.js versions from the repository +root: + ```bash -npm install -npm run dev +mise install ``` +Start the Go API from the repository root: + +```bash +mise exec -- go run . serve +``` + +Create a development account through the existing public API if needed: + +```bash +curl --request POST http://127.0.0.1:10086/api/v1/auth/register \ + --header 'Content-Type: application/json' \ + --data '{"username":"web-user","email":"web@example.com","password":"password123"}' +``` + +Then start the browser client from `web/`: + +```bash +mise exec -- npm ci +mise exec -- npm run dev +``` + +Vite proxies `/api` to `http://127.0.0.1:10086`. Production deployments must +serve the static build and `/api/v1` from the same origin, normally through a +reverse proxy. + ## Checks ```bash -npm run check +mise exec -- npm run check ``` -The production build is emitted to `dist/` as static assets. See `docs/web/roadmap.md` at the repository root for architecture boundaries and planned dependencies. +The production build is emitted to `dist/` as static assets. See +`docs/web/roadmap.md` at the repository root for architecture boundaries and +planned dependencies. diff --git a/web/package-lock.json b/web/package-lock.json index b98c40e..0d549d1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "mygo-web", "version": "0.0.0", "dependencies": { + "@ant-design/icons": "^6.3.2", "@tanstack/react-query": "^5.101.2", "antd": "^6.5.1", "react": "^19.2.7", @@ -23,7 +24,8 @@ "oxlint": "^1.71.0", "tailwindcss": "^4.3.2", "typescript": "~6.0.2", - "vite": "^8.1.1" + "vite": "^8.1.1", + "vitest": "^4.1.10" }, "engines": { "node": ">=24 <25" @@ -1546,6 +1548,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.2", "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.2.tgz", @@ -1855,6 +1864,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", @@ -1911,6 +1945,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/antd": { "version": "6.5.1", "resolved": "https://registry.npmmirror.com/antd/-/antd-6.5.1.tgz", @@ -1974,6 +2121,26 @@ "react-dom": ">=18.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", @@ -1989,6 +2156,13 @@ "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie-es": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/cookie-es/-/cookie-es-3.1.1.tgz", @@ -2031,6 +2205,33 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -2386,6 +2587,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oxlint": { "version": "1.74.0", "resolved": "https://registry.npmmirror.com/oxlint/-/oxlint-1.74.0.tgz", @@ -2435,6 +2650,13 @@ } } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -2581,6 +2803,13 @@ "compute-scroll-into-view": "^3.0.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2591,6 +2820,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", @@ -2633,6 +2876,23 @@ "node": ">=12.22" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2650,6 +2910,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", @@ -2756,6 +3026,113 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/web/package.json b/web/package.json index 3563a94..6f07ed2 100644 --- a/web/package.json +++ b/web/package.json @@ -11,11 +11,13 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", + "test": "vitest run", "typecheck": "tsc -b", - "check": "npm run lint && npm run build", + "check": "npm run lint && npm run test && npm run build", "preview": "vite preview" }, "dependencies": { + "@ant-design/icons": "^6.3.2", "@tanstack/react-query": "^5.101.2", "antd": "^6.5.1", "react": "^19.2.7", @@ -31,6 +33,7 @@ "oxlint": "^1.71.0", "tailwindcss": "^4.3.2", "typescript": "~6.0.2", - "vite": "^8.1.1" + "vite": "^8.1.1", + "vitest": "^4.1.10" } } diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 0000000..62bdef1 --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,19 @@ +import { apiClient } from './client.ts' +import type { TokenPair } from './types.ts' + +export function login(email: string, password: string) { + return apiClient.request('/auth/login', { + auth: false, + method: 'POST', + json: { email, password }, + }) +} + +export function logout(refreshToken: string) { + return apiClient.request('/auth/logout', { + auth: false, + method: 'POST', + json: { refresh_token: refreshToken }, + responseType: 'void', + }) +} diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..f63aa8b --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createApiClient } from './client.ts' +import { createSessionStore } from './session.ts' + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('API client', () => { + it('adds the current access token to authenticated requests', async () => { + const store = createSessionStore() + store.set({ access_token: 'access-one', refresh_token: 'refresh-one' }) + const fetchImplementation = vi.fn() + fetchImplementation.mockResolvedValue(jsonResponse({ ok: true })) + const client = createApiClient({ + apiBase: '/test-api', + fetchImplementation, + store, + }) + + await expect(client.request('/files')).resolves.toEqual({ ok: true }) + + const [, options] = fetchImplementation.mock.calls[0] + expect(new Headers(options?.headers).get('Authorization')).toBe( + 'Bearer access-one', + ) + }) + + it('shares one refresh across concurrent 401 responses and retries both requests', async () => { + const store = createSessionStore() + store.set({ access_token: 'expired-access', refresh_token: 'refresh-one' }) + + let resolveRefresh: ((response: Response) => void) | undefined + const refreshResponse = new Promise((resolve) => { + resolveRefresh = resolve + }) + const fetchImplementation = vi.fn(async (input, options) => { + const url = String(input) + if (url.endsWith('/auth/refresh')) { + return refreshResponse + } + + const authorization = new Headers(options?.headers).get('Authorization') + if (authorization === 'Bearer expired-access') { + return jsonResponse({ error: { message: 'expired' } }, 401) + } + return jsonResponse({ url }) + }) + const client = createApiClient({ fetchImplementation, store }) + + const requests = Promise.all([ + client.request('/files?offset=0'), + client.request('/files?offset=50'), + ]) + + await vi.waitFor(() => { + expect( + fetchImplementation.mock.calls.filter(([input]) => + String(input).endsWith('/auth/refresh'), + ), + ).toHaveLength(1) + }) + resolveRefresh?.( + jsonResponse({ + access_token: 'access-two', + refresh_token: 'refresh-two', + }), + ) + + await expect(requests).resolves.toHaveLength(2) + expect(store.get()).toEqual({ + access_token: 'access-two', + refresh_token: 'refresh-two', + }) + expect(fetchImplementation).toHaveBeenCalledTimes(5) + }) + + it('clears the session when refresh fails', async () => { + const store = createSessionStore() + store.set({ access_token: 'expired-access', refresh_token: 'expired-refresh' }) + const fetchImplementation = vi.fn() + fetchImplementation + .mockResolvedValueOnce(jsonResponse({ error: { message: 'expired' } }, 401)) + .mockResolvedValueOnce( + jsonResponse( + { error: { message: 'invalid token', log_id: 'log-123' } }, + 401, + ), + ) + const client = createApiClient({ fetchImplementation, store }) + + const request = client.request('/files') + + await expect(request).rejects.toMatchObject({ + status: 401, + message: 'invalid token', + logId: 'log-123', + }) + expect(store.get()).toBeNull() + }) + + it('clears the session when the retried request is still unauthorized', async () => { + const store = createSessionStore() + store.set({ access_token: 'expired-access', refresh_token: 'refresh-one' }) + const fetchImplementation = vi.fn() + fetchImplementation + .mockResolvedValueOnce(jsonResponse({ error: { message: 'expired' } }, 401)) + .mockResolvedValueOnce( + jsonResponse({ + access_token: 'access-two', + refresh_token: 'refresh-two', + }), + ) + .mockResolvedValueOnce(jsonResponse({ error: { message: 'denied' } }, 401)) + const client = createApiClient({ fetchImplementation, store }) + + await expect(client.request('/files')).rejects.toMatchObject({ + status: 401, + message: 'denied', + }) + expect(store.get()).toBeNull() + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..0518c40 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,159 @@ +import { sessionStore, type SessionStore } from './session.ts' +import type { ErrorResponse, TokenPair } from './types.ts' + +const defaultApiBase = '/api/v1' + +type ResponseType = 'blob' | 'json' | 'void' + +export interface ApiRequestOptions { + auth?: boolean + body?: BodyInit | null + headers?: HeadersInit + json?: unknown + method?: string + responseType?: ResponseType + signal?: AbortSignal +} + +interface ApiClientOptions { + apiBase?: string + fetchImplementation?: typeof fetch + store?: SessionStore +} + +export class ApiError extends Error { + readonly status: number + readonly logId?: string + + constructor(status: number, message: string, logId?: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.logId = logId + } +} + +async function responseError(response: Response): Promise { + let body: ErrorResponse | undefined + try { + body = (await response.json()) as ErrorResponse + } catch { + // Non-JSON failures use a stable fallback message. + } + + const message = + body?.error?.message ?? `Request failed with status ${response.status}` + return new ApiError(response.status, message, body?.error?.log_id) +} + +async function readResponse( + response: Response, + responseType: ResponseType, +): Promise { + if (!response.ok) { + throw await responseError(response) + } + + if (responseType === 'blob') { + return (await response.blob()) as T + } + if (responseType === 'void' || response.status === 204) { + return undefined as T + } + return (await response.json()) as T +} + +export function createApiClient({ + apiBase = defaultApiBase, + fetchImplementation = fetch, + store = sessionStore, +}: ApiClientOptions = {}) { + let refreshPromise: Promise | null = null + + const refreshSession = (): Promise => { + if (refreshPromise) { + return refreshPromise + } + + const refreshToken = store.get()?.refresh_token + if (!refreshToken) { + store.clear() + return Promise.reject(new ApiError(401, 'Your session has expired')) + } + + refreshPromise = fetchImplementation(`${apiBase}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }) + .then((response) => readResponse(response, 'json')) + .then((session) => { + store.set(session) + return session + }) + .catch((error: unknown) => { + store.clear() + throw error + }) + .finally(() => { + refreshPromise = null + }) + + return refreshPromise + } + + const request = async ( + path: string, + options: ApiRequestOptions = {}, + ): Promise => { + const { + auth = true, + body, + headers: providedHeaders, + json, + method = 'GET', + responseType = 'json', + signal, + } = options + + const run = (accessToken?: string) => { + const headers = new Headers(providedHeaders) + let requestBody = body + + if (json !== undefined) { + headers.set('Content-Type', 'application/json') + requestBody = JSON.stringify(json) + } + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`) + } + + return fetchImplementation(`${apiBase}${path}`, { + method, + headers, + body: requestBody, + signal, + }) + } + + const accessToken = auth ? store.get()?.access_token : undefined + if (auth && !accessToken) { + throw new ApiError(401, 'Authentication required') + } + + let response = await run(accessToken) + if (auth && response.status === 401) { + const refreshedSession = await refreshSession() + response = await run(refreshedSession.access_token) + if (response.status === 401) { + store.clear() + } + } + + return readResponse(response, responseType) + } + + return { request } +} + +export const apiClient = createApiClient() diff --git a/web/src/api/files.ts b/web/src/api/files.ts new file mode 100644 index 0000000..8ef3702 --- /dev/null +++ b/web/src/api/files.ts @@ -0,0 +1,27 @@ +import { apiClient } from './client.ts' +import type { FileInfo, FileListResponse } from './types.ts' + +export function listRootFiles(offset: number, limit: number, signal?: AbortSignal) { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + }) + return apiClient.request(`/files?${params}`, { signal }) +} + +export function uploadRootFile(file: File) { + const body = new FormData() + body.append('file', file) + + return apiClient.request('/files', { + method: 'POST', + body, + }) +} + +export function downloadFile(fileID: string) { + return apiClient.request( + `/files/${encodeURIComponent(fileID)}/content`, + { responseType: 'blob' }, + ) +} diff --git a/web/src/api/session.test.ts b/web/src/api/session.test.ts new file mode 100644 index 0000000..0d7b593 --- /dev/null +++ b/web/src/api/session.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createSessionStore } from './session.ts' +import type { TokenPair } from './types.ts' + +class MemoryStorage { + private readonly values = new Map() + + getItem(key: string) { + return this.values.get(key) ?? null + } + + removeItem(key: string) { + this.values.delete(key) + } + + setItem(key: string, value: string) { + this.values.set(key, value) + } +} + +const session: TokenPair = { + access_token: 'access-token', + refresh_token: 'refresh-token', +} + +describe('session store', () => { + it('persists and reloads a valid token pair', () => { + const storage = new MemoryStorage() + const store = createSessionStore(storage) + + store.set(session) + + expect(store.get()).toEqual(session) + expect(createSessionStore(storage).get()).toEqual(session) + }) + + it('notifies subscribers when the session changes', () => { + const store = createSessionStore(new MemoryStorage()) + const listener = vi.fn() + const unsubscribe = store.subscribe(listener) + + store.set(session) + store.clear() + unsubscribe() + store.set(session) + + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('discards malformed browser state', () => { + const storage = new MemoryStorage() + storage.setItem('mygo.auth.session', '{"access_token":true}') + + const store = createSessionStore(storage) + + expect(store.get()).toBeNull() + expect(storage.getItem('mygo.auth.session')).toBeNull() + }) +}) diff --git a/web/src/api/session.ts b/web/src/api/session.ts new file mode 100644 index 0000000..b8eb769 --- /dev/null +++ b/web/src/api/session.ts @@ -0,0 +1,87 @@ +import type { TokenPair } from './types.ts' + +const sessionStorageKey = 'mygo.auth.session' + +type SessionListener = () => void +type SessionStorage = Pick + +export interface SessionStore { + clear: () => void + get: () => TokenPair | null + set: (session: TokenPair) => void + subscribe: (listener: SessionListener) => () => void +} + +function isTokenPair(value: unknown): value is TokenPair { + if (typeof value !== 'object' || value === null) { + return false + } + + const candidate = value as Partial + return ( + typeof candidate.access_token === 'string' && + candidate.access_token.length > 0 && + typeof candidate.refresh_token === 'string' && + candidate.refresh_token.length > 0 + ) +} + +function readStoredSession(storage?: SessionStorage): TokenPair | null { + if (!storage) { + return null + } + + const raw = storage.getItem(sessionStorageKey) + if (!raw) { + return null + } + + try { + const parsed: unknown = JSON.parse(raw) + if (isTokenPair(parsed)) { + return parsed + } + } catch { + // Invalid browser state is treated as a signed-out session. + } + + storage.removeItem(sessionStorageKey) + return null +} + +export function createSessionStore(storage?: SessionStorage): SessionStore { + let currentSession = readStoredSession(storage) + const listeners = new Set() + + const notify = () => { + for (const listener of listeners) { + listener() + } + } + + return { + get: () => currentSession, + set: (session) => { + currentSession = session + storage?.setItem(sessionStorageKey, JSON.stringify(session)) + notify() + }, + clear: () => { + if (currentSession === null && !storage?.getItem(sessionStorageKey)) { + return + } + currentSession = null + storage?.removeItem(sessionStorageKey) + notify() + }, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} + +const browserStorage = + typeof window === 'undefined' ? undefined : window.sessionStorage + +export const sessionStore = createSessionStore(browserStorage) diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..511ebf3 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,28 @@ +export interface TokenPair { + access_token: string + refresh_token: string +} + +export interface ErrorResponse { + error: { + message: string + log_id?: string + } +} + +export interface FileInfo { + id: string + user_id: string + parent_id: string | null + name: string + size: number + mime_type: string + is_dir: boolean + created_at: string + updated_at: string +} + +export interface FileListResponse { + files: FileInfo[] + total: number +} diff --git a/web/src/app/RequireAuth.tsx b/web/src/app/RequireAuth.tsx new file mode 100644 index 0000000..3a3a377 --- /dev/null +++ b/web/src/app/RequireAuth.tsx @@ -0,0 +1,15 @@ +import { Navigate, Outlet, useLocation } from 'react-router' + +import { useAuthSession } from '../features/auth/useAuthSession.ts' + +export function RequireAuth() { + const session = useAuthSession() + const location = useLocation() + + if (!session) { + const from = `${location.pathname}${location.search}${location.hash}` + return + } + + return +} diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx index 8077714..215ab50 100644 --- a/web/src/app/providers.tsx +++ b/web/src/app/providers.tsx @@ -1,14 +1,60 @@ -import type { PropsWithChildren } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + QueryClient, + QueryClientProvider, + useQueryClient, +} from '@tanstack/react-query' import { App as AntDesignApp, ConfigProvider } from 'antd' +import { useEffect, useRef, type PropsWithChildren } from 'react' -const queryClient = new QueryClient() +import { ApiError } from '../api/client.ts' +import { useAuthSession } from '../features/auth/useAuthSession.ts' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status < 500) { + return false + } + return failureCount < 2 + }, + }, + mutations: { + retry: false, + }, + }, +}) + +function AuthCacheBoundary({ children }: PropsWithChildren) { + const session = useAuthSession() + const activeQueryClient = useQueryClient() + const previousSession = useRef(session) + + useEffect(() => { + if (previousSession.current && !session) { + activeQueryClient.clear() + } + previousSession.current = session + }, [activeQueryClient, session]) + + return children +} export function AppProviders({ children }: PropsWithChildren) { return ( - - {children} + + + {children} + ) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index ff4db0b..30b0369 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -1,12 +1,29 @@ import { createBrowserRouter } from 'react-router' -import { HomePage } from '../pages/HomePage.tsx' +import { AppShell } from '../components/AppShell.tsx' +import { FilesPage } from '../pages/FilesPage.tsx' +import { LoginPage } from '../pages/LoginPage.tsx' import { NotFoundPage } from '../pages/NotFoundPage.tsx' +import { RequireAuth } from './RequireAuth.tsx' export const router = createBrowserRouter([ { - path: '/', - element: , + path: '/login', + element: , + }, + { + element: , + children: [ + { + element: , + children: [ + { + path: '/', + element: , + }, + ], + }, + ], }, { path: '*', diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx new file mode 100644 index 0000000..ead4c27 --- /dev/null +++ b/web/src/components/AppShell.tsx @@ -0,0 +1,86 @@ +import { + CloudOutlined, + FolderOpenOutlined, + LogoutOutlined, +} from '@ant-design/icons' +import { useQueryClient } from '@tanstack/react-query' +import { App, Button, Layout, Menu, Typography } from 'antd' +import { useState } from 'react' +import { Outlet, useLocation, useNavigate } from 'react-router' + +import { logout } from '../api/auth.ts' +import { ApiError } from '../api/client.ts' +import { sessionStore } from '../api/session.ts' + +const { Content, Header } = Layout + +export function AppShell() { + const { message } = App.useApp() + const location = useLocation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [isLoggingOut, setIsLoggingOut] = useState(false) + + const handleLogout = async () => { + const refreshToken = sessionStore.get()?.refresh_token + setIsLoggingOut(true) + + try { + if (refreshToken) { + await logout(refreshToken) + } + } catch (error) { + const detail = error instanceof ApiError ? error.message : 'Server unavailable' + message.warning(`Signed out locally. ${detail}`) + } finally { + sessionStore.clear() + queryClient.clear() + navigate('/login', { replace: true }) + setIsLoggingOut(false) + } + } + + return ( + +
+ + , + label: 'Files', + onClick: () => navigate('/'), + }, + ]} + /> + +
+ +
+ +
+
+
+ ) +} diff --git a/web/src/features/auth/useAuthSession.ts b/web/src/features/auth/useAuthSession.ts new file mode 100644 index 0000000..0a34c77 --- /dev/null +++ b/web/src/features/auth/useAuthSession.ts @@ -0,0 +1,11 @@ +import { useSyncExternalStore } from 'react' + +import { sessionStore } from '../../api/session.ts' + +export function useAuthSession() { + return useSyncExternalStore( + sessionStore.subscribe, + sessionStore.get, + sessionStore.get, + ) +} diff --git a/web/src/index.css b/web/src/index.css index 1ac8c8a..dff9e02 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -5,9 +5,10 @@ html, body, #root { min-width: 320px; - min-height: 100%; + min-height: 100vh; } body { margin: 0; + background: #f5f7fb; } diff --git a/web/src/pages/FilesPage.tsx b/web/src/pages/FilesPage.tsx new file mode 100644 index 0000000..eb2487d --- /dev/null +++ b/web/src/pages/FilesPage.tsx @@ -0,0 +1,241 @@ +import { + DownloadOutlined, + FileOutlined, + FolderFilled, + ReloadOutlined, + UploadOutlined, +} from '@ant-design/icons' +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Card, + Empty, + Space, + Table, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { useRef, useState, type ChangeEvent } from 'react' + +import { ApiError } from '../api/client.ts' +import { downloadFile, listRootFiles, uploadRootFile } from '../api/files.ts' +import type { FileInfo } from '../api/types.ts' + +const pageSize = 50 + +function formatBytes(bytes: number) { + if (bytes === 0) { + return '0 B' + } + + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] + const unitIndex = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ) + const value = bytes / 1024 ** unitIndex + return `${value.toFixed(unitIndex === 0 || value >= 10 ? 0 : 1)} ${units[unitIndex]}` +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)) +} + +function errorMessage(error: unknown) { + return error instanceof ApiError ? error.message : 'Unable to reach the server' +} + +export function FilesPage() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const fileInput = useRef(null) + const [page, setPage] = useState(1) + const [downloadingID, setDownloadingID] = useState(null) + + const filesQuery = useQuery({ + queryKey: ['files', 'root', page], + queryFn: ({ signal }) => listRootFiles((page - 1) * pageSize, pageSize, signal), + placeholderData: keepPreviousData, + }) + + const uploadMutation = useMutation({ + mutationFn: uploadRootFile, + onSuccess: async (file) => { + message.success(`${file.name} uploaded`) + setPage(1) + await queryClient.invalidateQueries({ queryKey: ['files', 'root'] }) + }, + onError: (error) => message.error(errorMessage(error)), + }) + + const handleFileSelected = (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + if (file && !uploadMutation.isPending) { + uploadMutation.mutate(file) + } + } + + const handleDownload = async (file: FileInfo) => { + setDownloadingID(file.id) + try { + const blob = await downloadFile(file.id) + const objectURL = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = objectURL + anchor.download = file.name + document.body.append(anchor) + anchor.click() + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(objectURL), 0) + } catch (error) { + message.error(errorMessage(error)) + } finally { + setDownloadingID(null) + } + } + + const columns: TableColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (name: string, file) => ( + + {file.is_dir ? ( + + ) : ( + + )} + {name} + + ), + }, + { + title: 'Type', + key: 'type', + width: 220, + responsive: ['md'], + render: (_, file) => + file.is_dir ? Folder : file.mime_type || 'File', + }, + { + title: 'Size', + dataIndex: 'size', + key: 'size', + width: 120, + responsive: ['sm'], + render: (size: number, file) => (file.is_dir ? '—' : formatBytes(size)), + }, + { + title: 'Modified', + dataIndex: 'updated_at', + key: 'updated_at', + width: 190, + responsive: ['lg'], + render: formatDate, + }, + { + title: 'Action', + key: 'action', + align: 'right', + width: 120, + render: (_, file) => + file.is_dir ? ( + + ) : ( + + ), + }, + ] + + if (filesQuery.isError && !filesQuery.data) { + return ( + } onClick={() => filesQuery.refetch()}> + Retry + + } + /> + ) + } + + return ( + +
+
+ + Files + + + Files stored in your root directory. + +
+ + +
+ + + rowKey="id" + columns={columns} + dataSource={filesQuery.data?.files ?? []} + loading={filesQuery.isLoading || filesQuery.isFetching} + locale={{ + emptyText: ( + + + + ), + }} + pagination={{ + current: page, + pageSize, + total: filesQuery.data?.total ?? 0, + showSizeChanger: false, + showTotal: (total) => `${total} item${total === 1 ? '' : 's'}`, + onChange: setPage, + }} + scroll={{ x: 640 }} + /> +
+ ) +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx deleted file mode 100644 index d759bcc..0000000 --- a/web/src/pages/HomePage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Card, Space, Tag, Typography } from 'antd' - -const { Paragraph, Title } = Typography - -export function HomePage() { - return ( -
- - - MyGO Web - MyGO - - The client-side web application foundation is ready. - - - -
- ) -} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..9845ba2 --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -0,0 +1,113 @@ +import { CloudOutlined, LockOutlined, MailOutlined } from '@ant-design/icons' +import { useMutation } from '@tanstack/react-query' +import { Alert, Button, Card, Form, Input, Space, Typography } from 'antd' +import { Navigate, useLocation, useNavigate } from 'react-router' + +import { login } from '../api/auth.ts' +import { ApiError } from '../api/client.ts' +import { sessionStore } from '../api/session.ts' +import { useAuthSession } from '../features/auth/useAuthSession.ts' + +interface LoginValues { + email: string + password: string +} + +interface LoginLocationState { + from?: unknown +} + +function destinationFromState(state: unknown) { + const from = (state as LoginLocationState | null)?.from + return typeof from === 'string' && from.startsWith('/') ? from : '/' +} + +export function LoginPage() { + const session = useAuthSession() + const location = useLocation() + const navigate = useNavigate() + const destination = destinationFromState(location.state) + + const loginMutation = useMutation({ + mutationFn: ({ email, password }: LoginValues) => login(email, password), + onSuccess: (tokenPair) => { + sessionStore.set(tokenPair) + navigate(destination, { replace: true }) + }, + }) + + if (session) { + return + } + + const errorMessage = loginMutation.error + ? loginMutation.error instanceof ApiError + ? loginMutation.error.message + : 'Unable to reach the server' + : null + + return ( +
+ + +
+ + + Sign in to MyGO + + + Access your files from this browser session. + +
+ + {errorMessage && ( + + )} + + + layout="vertical" + requiredMark={false} + onFinish={(values) => loginMutation.mutate(values)} + > + + } + placeholder="you@example.com" + size="large" + /> + + + } + placeholder="Password" + size="large" + /> + + + +
+
+
+ ) +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 0616e59..3c382de 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -4,4 +4,9 @@ import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': 'http://127.0.0.1:10086', + }, + }, }) diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..e5f4025 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.{ts,tsx}'], + }, +})