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.
This commit is contained in:
2026-07-15 20:16:11 +08:00
parent 99e5758d4c
commit 04eb8727eb
24 changed files with 1524 additions and 42 deletions
+7 -4
View File
@@ -2,7 +2,8 @@
MyGO is a WebDisk (cloud drive) server, written in Go. 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 ### v0
- [ ] CLI configuration management (`mygo config`) - [ ] CLI configuration management (`mygo config`)
- [ ] User authentication (JWT) - [x] User authentication (JWT)
- [ ] File upload / download / management (HTTP API) - [x] Basic file upload / download / management (HTTP API)
- [ ] Admin endpoints - [ ] Admin endpoints
- [ ] WebDAV support - [ ] WebDAV support
@@ -39,7 +40,9 @@ MyGO is a WebDisk (cloud drive) server, written in Go.
## Frontend ## 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/`.
--- ---
+26
View File
@@ -17,3 +17,29 @@
- The Web and future native clients consume the same client-neutral API contracts. - 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 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. - 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.
+17 -3
View File
@@ -8,9 +8,23 @@
- Production may serve `web/dist` from MyGO or a reverse proxy. - Production may serve `web/dist` from MyGO or a reverse proxy.
- Shared API contracts should remain client-neutral and eventually be described by OpenAPI. - 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 ## Foundation
The initial project contains only the framework and styling foundation: The project foundation contains:
- Node.js 24 and npm - Node.js 24 and npm
- Vite - Vite
@@ -19,7 +33,9 @@ The initial project contains only the framework and styling foundation:
- TanStack Query provider - TanStack Query provider
- Tailwind CSS 4 through its Vite plugin - Tailwind CSS 4 through its Vite plugin
- Ant Design provider and components - Ant Design provider and components
- Ant Design icons for application and file actions
- Oxlint from the Vite template - 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. 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: 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-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-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. - `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. - `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. - `pdfjs-dist`: Preview PDF files in the browser when document preview is implemented.
+33 -4
View File
@@ -4,15 +4,44 @@ MyGO's browser client is a pure client-side rendered application built with Reac
## Development ## Development
Install the repository-pinned Go and Node.js versions from the repository
root:
```bash ```bash
npm install mise install
npm run dev
``` ```
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 ## Checks
```bash ```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.
+378 -1
View File
@@ -8,6 +8,7 @@
"name": "mygo-web", "name": "mygo-web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"antd": "^6.5.1", "antd": "^6.5.1",
"react": "^19.2.7", "react": "^19.2.7",
@@ -23,7 +24,8 @@
"oxlint": "^1.71.0", "oxlint": "^1.71.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.1" "vite": "^8.1.1",
"vitest": "^4.1.10"
}, },
"engines": { "engines": {
"node": ">=24 <25" "node": ">=24 <25"
@@ -1546,6 +1548,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@tailwindcss/node": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.2.tgz", "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.2.tgz",
@@ -1855,6 +1864,31 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", "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": { "node_modules/antd": {
"version": "6.5.1", "version": "6.5.1",
"resolved": "https://registry.npmmirror.com/antd/-/antd-6.5.1.tgz", "resolved": "https://registry.npmmirror.com/antd/-/antd-6.5.1.tgz",
@@ -1974,6 +2121,26 @@
"react-dom": ">=18.0.0" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
@@ -1989,6 +2156,13 @@
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
"license": "MIT" "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": { "node_modules/cookie-es": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmmirror.com/cookie-es/-/cookie-es-3.1.1.tgz", "resolved": "https://registry.npmmirror.com/cookie-es/-/cookie-es-3.1.1.tgz",
@@ -2031,6 +2205,33 @@
"node": ">=10.13.0" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", "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": "^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": { "node_modules/oxlint": {
"version": "1.74.0", "version": "1.74.0",
"resolved": "https://registry.npmmirror.com/oxlint/-/oxlint-1.74.0.tgz", "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -2581,6 +2803,13 @@
"compute-scroll-into-view": "^3.0.2" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2591,6 +2820,20 @@
"node": ">=0.10.0" "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": { "node_modules/string-convert": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz",
@@ -2633,6 +2876,23 @@
"node": ">=12.22" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2650,6 +2910,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
@@ -2756,6 +3026,113 @@
"optional": true "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"
}
} }
} }
} }
+5 -2
View File
@@ -11,11 +11,13 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"test": "vitest run",
"typecheck": "tsc -b", "typecheck": "tsc -b",
"check": "npm run lint && npm run build", "check": "npm run lint && npm run test && npm run build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"antd": "^6.5.1", "antd": "^6.5.1",
"react": "^19.2.7", "react": "^19.2.7",
@@ -31,6 +33,7 @@
"oxlint": "^1.71.0", "oxlint": "^1.71.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.1" "vite": "^8.1.1",
"vitest": "^4.1.10"
} }
} }
+19
View File
@@ -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<TokenPair>('/auth/login', {
auth: false,
method: 'POST',
json: { email, password },
})
}
export function logout(refreshToken: string) {
return apiClient.request<void>('/auth/logout', {
auth: false,
method: 'POST',
json: { refresh_token: refreshToken },
responseType: 'void',
})
}
+127
View File
@@ -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<typeof fetch>()
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<Response>((resolve) => {
resolveRefresh = resolve
})
const fetchImplementation = vi.fn<typeof fetch>(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<typeof fetch>()
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<typeof fetch>()
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()
})
})
+159
View File
@@ -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<ApiError> {
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<T>(
response: Response,
responseType: ResponseType,
): Promise<T> {
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<TokenPair> | null = null
const refreshSession = (): Promise<TokenPair> => {
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<TokenPair>(response, 'json'))
.then((session) => {
store.set(session)
return session
})
.catch((error: unknown) => {
store.clear()
throw error
})
.finally(() => {
refreshPromise = null
})
return refreshPromise
}
const request = async <T>(
path: string,
options: ApiRequestOptions = {},
): Promise<T> => {
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<T>(response, responseType)
}
return { request }
}
export const apiClient = createApiClient()
+27
View File
@@ -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<FileListResponse>(`/files?${params}`, { signal })
}
export function uploadRootFile(file: File) {
const body = new FormData()
body.append('file', file)
return apiClient.request<FileInfo>('/files', {
method: 'POST',
body,
})
}
export function downloadFile(fileID: string) {
return apiClient.request<Blob>(
`/files/${encodeURIComponent(fileID)}/content`,
{ responseType: 'blob' },
)
}
+60
View File
@@ -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<string, string>()
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()
})
})
+87
View File
@@ -0,0 +1,87 @@
import type { TokenPair } from './types.ts'
const sessionStorageKey = 'mygo.auth.session'
type SessionListener = () => void
type SessionStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>
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<TokenPair>
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<SessionListener>()
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)
+28
View File
@@ -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
}
+15
View File
@@ -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 <Navigate to="/login" replace state={{ from }} />
}
return <Outlet />
}
+51 -5
View File
@@ -1,14 +1,60 @@
import type { PropsWithChildren } from 'react' import {
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' QueryClient,
QueryClientProvider,
useQueryClient,
} from '@tanstack/react-query'
import { App as AntDesignApp, ConfigProvider } from 'antd' 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) { export function AppProviders({ children }: PropsWithChildren) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ConfigProvider> <ConfigProvider
<AntDesignApp>{children}</AntDesignApp> theme={{
token: {
borderRadius: 8,
colorBgLayout: '#f5f7fb',
colorPrimary: '#2563eb',
},
}}
>
<AntDesignApp>
<AuthCacheBoundary>{children}</AuthCacheBoundary>
</AntDesignApp>
</ConfigProvider> </ConfigProvider>
</QueryClientProvider> </QueryClientProvider>
) )
+19 -2
View File
@@ -1,12 +1,29 @@
import { createBrowserRouter } from 'react-router' 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 { NotFoundPage } from '../pages/NotFoundPage.tsx'
import { RequireAuth } from './RequireAuth.tsx'
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
},
{
element: <RequireAuth />,
children: [
{
element: <AppShell />,
children: [
{ {
path: '/', path: '/',
element: <HomePage />, element: <FilesPage />,
},
],
},
],
}, },
{ {
path: '*', path: '*',
+86
View File
@@ -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 (
<Layout className="min-h-screen">
<Header className="flex items-center gap-4 px-4 sm:px-6">
<button
type="button"
className="flex shrink-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-white"
onClick={() => navigate('/')}
>
<CloudOutlined className="text-xl" />
<Typography.Text className="hidden text-base font-semibold text-white sm:inline">
MyGO
</Typography.Text>
</button>
<Menu
className="min-w-0 flex-1"
theme="dark"
mode="horizontal"
selectedKeys={location.pathname === '/' ? ['files'] : []}
items={[
{
key: 'files',
icon: <FolderOpenOutlined />,
label: 'Files',
onClick: () => navigate('/'),
},
]}
/>
<Button
ghost
icon={<LogoutOutlined />}
loading={isLoggingOut}
onClick={handleLogout}
>
<span className="hidden sm:inline">Log out</span>
</Button>
</Header>
<Content className="p-4 sm:p-6">
<div className="mx-auto w-full max-w-7xl">
<Outlet />
</div>
</Content>
</Layout>
)
}
+11
View File
@@ -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,
)
}
+2 -1
View File
@@ -5,9 +5,10 @@ html,
body, body,
#root { #root {
min-width: 320px; min-width: 320px;
min-height: 100%; min-height: 100vh;
} }
body { body {
margin: 0; margin: 0;
background: #f5f7fb;
} }
+241
View File
@@ -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<HTMLInputElement>(null)
const [page, setPage] = useState(1)
const [downloadingID, setDownloadingID] = useState<string | null>(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<HTMLInputElement>) => {
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<FileInfo> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (name: string, file) => (
<Space>
{file.is_dir ? (
<FolderFilled className="text-amber-500" />
) : (
<FileOutlined className="text-blue-600" />
)}
<Typography.Text ellipsis={{ tooltip: name }}>{name}</Typography.Text>
</Space>
),
},
{
title: 'Type',
key: 'type',
width: 220,
responsive: ['md'],
render: (_, file) =>
file.is_dir ? <Tag color="gold">Folder</Tag> : 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 ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Button
type="link"
icon={<DownloadOutlined />}
loading={downloadingID === file.id}
onClick={() => handleDownload(file)}
>
Download
</Button>
),
},
]
if (filesQuery.isError && !filesQuery.data) {
return (
<Alert
showIcon
type="error"
message="Unable to load files"
description={errorMessage(filesQuery.error)}
action={
<Button icon={<ReloadOutlined />} onClick={() => filesQuery.refetch()}>
Retry
</Button>
}
/>
)
}
return (
<Card>
<div className="mb-5 flex flex-wrap items-start justify-between gap-4">
<div>
<Typography.Title level={2} className="mb-1!">
Files
</Typography.Title>
<Typography.Text type="secondary">
Files stored in your root directory.
</Typography.Text>
</div>
<input
ref={fileInput}
className="hidden"
type="file"
onChange={handleFileSelected}
/>
<Button
type="primary"
icon={<UploadOutlined />}
loading={uploadMutation.isPending}
onClick={() => fileInput.current?.click()}
>
Upload file
</Button>
</div>
<Table<FileInfo>
rowKey="id"
columns={columns}
dataSource={filesQuery.data?.files ?? []}
loading={filesQuery.isLoading || filesQuery.isFetching}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No files in your root directory"
>
<Button
type="primary"
loading={uploadMutation.isPending}
onClick={() => fileInput.current?.click()}
>
Upload your first file
</Button>
</Empty>
),
}}
pagination={{
current: page,
pageSize,
total: filesQuery.data?.total ?? 0,
showSizeChanger: false,
showTotal: (total) => `${total} item${total === 1 ? '' : 's'}`,
onChange: setPage,
}}
scroll={{ x: 640 }}
/>
</Card>
)
}
-19
View File
@@ -1,19 +0,0 @@
import { Card, Space, Tag, Typography } from 'antd'
const { Paragraph, Title } = Typography
export function HomePage() {
return (
<main className="grid min-h-screen place-items-center bg-slate-50 p-6">
<Card className="w-full max-w-xl">
<Space orientation="vertical" size="middle">
<Tag color="blue">MyGO Web</Tag>
<Title level={1}>MyGO</Title>
<Paragraph>
The client-side web application foundation is ready.
</Paragraph>
</Space>
</Card>
</main>
)
}
+113
View File
@@ -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 <Navigate to="/" replace />
}
const errorMessage = loginMutation.error
? loginMutation.error instanceof ApiError
? loginMutation.error.message
: 'Unable to reach the server'
: null
return (
<main className="grid min-h-screen place-items-center bg-slate-100 p-4">
<Card className="w-full max-w-md shadow-sm">
<Space orientation="vertical" size="large" className="w-full">
<div className="text-center">
<CloudOutlined className="mb-3 text-4xl text-blue-600" />
<Typography.Title level={1} className="mb-1!">
Sign in to MyGO
</Typography.Title>
<Typography.Text type="secondary">
Access your files from this browser session.
</Typography.Text>
</div>
{errorMessage && (
<Alert type="error" showIcon message="Sign-in failed" description={errorMessage} />
)}
<Form<LoginValues>
layout="vertical"
requiredMark={false}
onFinish={(values) => loginMutation.mutate(values)}
>
<Form.Item
label="Email"
name="email"
rules={[
{ required: true, message: 'Enter your email address' },
{ type: 'email', message: 'Enter a valid email address' },
]}
>
<Input
autoComplete="email"
prefix={<MailOutlined />}
placeholder="you@example.com"
size="large"
/>
</Form.Item>
<Form.Item
label="Password"
name="password"
rules={[{ required: true, message: 'Enter your password' }]}
>
<Input.Password
autoComplete="current-password"
prefix={<LockOutlined />}
placeholder="Password"
size="large"
/>
</Form.Item>
<Button
block
htmlType="submit"
loading={loginMutation.isPending}
size="large"
type="primary"
>
Sign in
</Button>
</Form>
</Space>
</Card>
</main>
)
}
+5
View File
@@ -4,4 +4,9 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://127.0.0.1:10086',
},
},
}) })
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.{ts,tsx}'],
},
})