Compare commits
63 Commits
jazz-inspe
...
jmsv/581/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b75914add6 | ||
|
|
7ad7e6b1f1 | ||
|
|
1c63bdc674 | ||
|
|
c535b5d5f3 | ||
|
|
85e927547c | ||
|
|
a111ebd96b | ||
|
|
238ba97ef6 | ||
|
|
35ab08447f | ||
|
|
d614da38db | ||
|
|
22103a371b | ||
|
|
2a83de5787 | ||
|
|
c353ae7199 | ||
|
|
8cf0180bdc | ||
|
|
3d65dd0d2f | ||
|
|
566eecb005 | ||
|
|
502ddb44a6 | ||
|
|
16d04cd053 | ||
|
|
b1d102ae77 | ||
|
|
be28248bd9 | ||
|
|
454a3e5458 | ||
|
|
54870d089d | ||
|
|
1c453726cf | ||
|
|
030ac42d57 | ||
|
|
3f500fcb43 | ||
|
|
8aab2c5ad1 | ||
|
|
8096f11f17 | ||
|
|
b5acd2ab88 | ||
|
|
892ca12b53 | ||
|
|
b131d04325 | ||
|
|
55e3277b00 | ||
|
|
03bb9df861 | ||
|
|
e0ce0dbe10 | ||
|
|
9576efe324 | ||
|
|
0082417f76 | ||
|
|
135d239aa7 | ||
|
|
6a80bea57f | ||
|
|
f439b3afc2 | ||
|
|
399590598c | ||
|
|
c9155b3134 | ||
|
|
0499f81d35 | ||
|
|
60ccdd0497 | ||
|
|
a09ef59fd8 | ||
|
|
9a08df4481 | ||
|
|
99ad494585 | ||
|
|
133696f3d4 | ||
|
|
6ba97e0427 | ||
|
|
e0c65e240b | ||
|
|
a6941ba257 | ||
|
|
4d30d6dc00 | ||
|
|
4601438890 | ||
|
|
e606bd4c35 | ||
|
|
8311901047 | ||
|
|
15ab4e9140 | ||
|
|
0b73ece3d6 | ||
|
|
d2008bf9db | ||
|
|
54ce017008 | ||
|
|
851b1f9679 | ||
|
|
454a973ab3 | ||
|
|
317f143929 | ||
|
|
fb34f50242 | ||
|
|
2fd3373fe7 | ||
|
|
ba2f833c93 | ||
|
|
6450cf69ba |
5
.changeset/beige-experts-retire.md
Normal file
5
.changeset/beige-experts-retire.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cojson": patch
|
||||
---
|
||||
|
||||
Pass deletePeerStateOnClose through connectedPeers
|
||||
7
.changeset/warm-forks-clean.md
Normal file
7
.changeset/warm-forks-clean.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"jazz-react-core": minor
|
||||
"jazz-react": minor
|
||||
"jazz-tools": minor
|
||||
---
|
||||
|
||||
Cross-Device Account Transfer
|
||||
28
examples/cross-device-account-transfer/.gitignore
vendored
Normal file
28
examples/cross-device-account-transfer/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
playwright-report
|
||||
319
examples/cross-device-account-transfer/CHANGELOG.md
Normal file
319
examples/cross-device-account-transfer/CHANGELOG.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# jazz-tailwind-demo-auth-starter
|
||||
|
||||
## 0.0.58
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.11.5
|
||||
- jazz-tools@0.11.5
|
||||
|
||||
## 0.0.57
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [57a3dbe]
|
||||
- Updated dependencies [a717754]
|
||||
- Updated dependencies [a91f343]
|
||||
- jazz-tools@0.11.4
|
||||
- jazz-react@0.11.4
|
||||
|
||||
## 0.0.56
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.11.3
|
||||
- jazz-tools@0.11.3
|
||||
|
||||
## 0.0.55
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6892dc6]
|
||||
- jazz-tools@0.11.2
|
||||
- jazz-react@0.11.2
|
||||
|
||||
## 0.0.54
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.11.1
|
||||
|
||||
## 0.0.53
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6a96d8b]
|
||||
- Updated dependencies [a35249a]
|
||||
- Updated dependencies [b9d194a]
|
||||
- Updated dependencies [a4713df]
|
||||
- Updated dependencies [34cbdc3]
|
||||
- Updated dependencies [f039e8f]
|
||||
- Updated dependencies [e22de9f]
|
||||
- jazz-tools@0.11.0
|
||||
- jazz-react@0.11.0
|
||||
|
||||
## 0.0.52
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [2f99de0]
|
||||
- jazz-tools@0.10.15
|
||||
- jazz-react@0.10.15
|
||||
|
||||
## 0.0.51
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [75211e3]
|
||||
- jazz-tools@0.10.14
|
||||
- jazz-react@0.10.14
|
||||
|
||||
## 0.0.50
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [07feedd]
|
||||
- jazz-tools@0.10.13
|
||||
- jazz-react@0.10.13
|
||||
|
||||
## 0.0.49
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [4612e05]
|
||||
- jazz-tools@0.10.12
|
||||
- jazz-react@0.10.12
|
||||
|
||||
## 0.0.48
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.9
|
||||
|
||||
## 0.0.47
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [2fb6428]
|
||||
- jazz-tools@0.10.8
|
||||
- jazz-react@0.10.8
|
||||
|
||||
## 0.0.46
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1136d9b]
|
||||
- Updated dependencies [0eed228]
|
||||
- jazz-react@0.10.7
|
||||
- jazz-tools@0.10.7
|
||||
|
||||
## 0.0.45
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1d71ca1]
|
||||
- Updated dependencies [ada802b]
|
||||
- jazz-react@0.10.6
|
||||
- jazz-tools@0.10.6
|
||||
|
||||
## 0.0.44
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [59ff77e]
|
||||
- jazz-tools@0.10.5
|
||||
- jazz-react@0.10.5
|
||||
|
||||
## 0.0.43
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.4
|
||||
- jazz-tools@0.10.4
|
||||
|
||||
## 0.0.42
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d8582fc]
|
||||
- jazz-tools@0.10.3
|
||||
- jazz-react@0.10.3
|
||||
|
||||
## 0.0.41
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 0.0.40
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.39
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [498954f]
|
||||
- Updated dependencies [d42c2aa]
|
||||
- Updated dependencies [dd03464]
|
||||
- Updated dependencies [b426342]
|
||||
- jazz-react@0.10.0
|
||||
- jazz-tools@0.10.0
|
||||
|
||||
## 0.0.38
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.23
|
||||
- jazz-tools@0.9.23
|
||||
|
||||
## 0.0.37
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.22
|
||||
|
||||
## 0.0.36
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1be017d]
|
||||
- jazz-tools@0.9.21
|
||||
- jazz-react@0.9.21
|
||||
|
||||
## 0.0.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b01cc1f]
|
||||
- jazz-tools@0.9.20
|
||||
- jazz-react@0.9.20
|
||||
|
||||
## 0.0.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.19
|
||||
- jazz-tools@0.9.19
|
||||
|
||||
## 0.0.33
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.18
|
||||
- jazz-tools@0.9.18
|
||||
|
||||
## 0.0.32
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c2ca1fe]
|
||||
- Updated dependencies [1227047]
|
||||
- jazz-tools@0.9.17
|
||||
- jazz-react@0.9.17
|
||||
|
||||
## 0.0.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [24b3b6a]
|
||||
- jazz-tools@0.9.16
|
||||
- jazz-react@0.9.16
|
||||
|
||||
## 0.0.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [7491711]
|
||||
- jazz-tools@0.9.15
|
||||
- jazz-react@0.9.15
|
||||
|
||||
## 0.0.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3df93cc]
|
||||
- jazz-tools@0.9.14
|
||||
- jazz-react@0.9.14
|
||||
|
||||
## 0.0.28
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.13
|
||||
- jazz-tools@0.9.13
|
||||
|
||||
## 0.0.27
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.12
|
||||
- jazz-tools@0.9.12
|
||||
|
||||
## 0.0.26
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.11
|
||||
- jazz-tools@0.9.11
|
||||
|
||||
## 0.0.25
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5e83864]
|
||||
- jazz-react@0.9.10
|
||||
- jazz-tools@0.9.10
|
||||
|
||||
## 0.0.24
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8eb9247]
|
||||
- jazz-tools@0.9.9
|
||||
- jazz-react@0.9.9
|
||||
|
||||
## 0.0.23
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d1d773b]
|
||||
- jazz-tools@0.9.8
|
||||
- jazz-react@0.9.8
|
||||
|
||||
## 0.0.22
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.9.4
|
||||
|
||||
## 0.0.21
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1b71969]
|
||||
- jazz-react@0.9.1
|
||||
- jazz-tools@0.9.1
|
||||
|
||||
## 0.0.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [956a4d1]
|
||||
- Updated dependencies [8eda792]
|
||||
- jazz-react@0.9.0
|
||||
- jazz-tools@0.9.0
|
||||
|
||||
## 0.0.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dc62b95]
|
||||
- Updated dependencies [1de26f8]
|
||||
- jazz-tools@0.8.51
|
||||
- jazz-react@0.8.51
|
||||
50
examples/cross-device-account-transfer/README.md
Normal file
50
examples/cross-device-account-transfer/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Jazz React starter with Tailwind and Demo Auth
|
||||
|
||||
A minimal starter template for building apps with **[Jazz](https://jazz.tools)**, React, TailwindCSS, and Demo Auth.
|
||||
|
||||
## Creating an app
|
||||
|
||||
Create a new Jazz app.
|
||||
```bash
|
||||
npx create-jazz-app@latest
|
||||
```
|
||||
|
||||
Then select "React + Jazz + Demo Auth + Tailwind".
|
||||
|
||||
## Running locally
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm i
|
||||
# or
|
||||
yarn
|
||||
```
|
||||
|
||||
Then, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:5173](http://localhost:5173) with your browser to see the result.
|
||||
|
||||
## Learning Jazz
|
||||
|
||||
You can start by playing with the form, adding a new field in [./src/schema.ts](./src/schema.ts),
|
||||
and seeing how easy it is to structure your data, and perform basic operations.
|
||||
|
||||
To learn more, check out the [full tutorial](https://jazz.tools/docs/react/guide).
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the app uses [Jazz Cloud](https://jazz.tools/cloud) (`wss://cloud.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx jazz-run sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?peer=ws://localhost:4200`), or by setting the `sync` parameter of the `<Jazz.Provider>` provider component in [./src/main.tsx](./src/main.tsx).
|
||||
BIN
examples/cross-device-account-transfer/favicon.ico
Normal file
BIN
examples/cross-device-account-transfer/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
13
examples/cross-device-account-transfer/index.html
Normal file
13
examples/cross-device-account-transfer/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz | React + Cross-Device Account Transfer + Tailwind</title>
|
||||
</head>
|
||||
<body class="h-full flex flex-col">
|
||||
<div id="root" class="align-self-center flex-1"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
examples/cross-device-account-transfer/package.json
Normal file
38
examples/cross-device-account-transfer/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "cross-device-account-transfer",
|
||||
"private": true,
|
||||
"version": "0.0.58",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"format-and-lint": "biome check .",
|
||||
"format-and-lint:fix": "biome check . --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"input-otp": "^1.4.2",
|
||||
"jazz-react": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router": "^6.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"globals": "^15.11.0",
|
||||
"is-ci": "^3.0.1",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.11"
|
||||
}
|
||||
}
|
||||
6
examples/cross-device-account-transfer/postcss.config.js
Normal file
6
examples/cross-device-account-transfer/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
23
examples/cross-device-account-transfer/src/App.tsx
Normal file
23
examples/cross-device-account-transfer/src/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Route, Routes } from "react-router";
|
||||
import CrossDeviceAccountTransferHandlerSourcePage from "./components/pages/CrossDeviceAccountTransferHandlerSource.tsx";
|
||||
import CrossDeviceAccountTransferHandlerTargetPage from "./components/pages/CrossDeviceAccountTransferHandlerTarget.tsx";
|
||||
import HomePage from "./components/pages/HomePage.tsx";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
|
||||
<Route
|
||||
path="/accept-account-transfer/:transferId/:inviteSecret"
|
||||
element={<CrossDeviceAccountTransferHandlerTargetPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/share-current-account/:transferId/:inviteSecret"
|
||||
element={<CrossDeviceAccountTransferHandlerSourcePage />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
examples/cross-device-account-transfer/src/apiKey.ts
Normal file
1
examples/cross-device-account-transfer/src/apiKey.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const apiKey = "react-passkey-auth@garden.co";
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useAccount, usePasskeyAuth } from "jazz-react";
|
||||
import { useState } from "react";
|
||||
import { APPLICATION_NAME } from "../main";
|
||||
import { Button } from "./Button";
|
||||
import { Card } from "./Card";
|
||||
import { CreateCrossDeviceAccountTransferAsSource } from "./CreateCrossDeviceAccountTransferAsSource";
|
||||
import { CreateCrossDeviceAccountTransferAsTarget } from "./CreateCrossDeviceAccountTransferAsTarget";
|
||||
|
||||
export function AuthButtons() {
|
||||
const { logOut } = useAccount();
|
||||
|
||||
const auth = usePasskeyAuth({ appName: APPLICATION_NAME });
|
||||
|
||||
const [accountTransferFlow, setAccountTransferFlow] = useState(false);
|
||||
|
||||
function handleLogOut() {
|
||||
logOut();
|
||||
window.history.pushState({}, "", "/");
|
||||
}
|
||||
|
||||
if (auth.state === "signedIn") {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
<Button color="destructive" onClick={handleLogOut}>
|
||||
Log out
|
||||
</Button>
|
||||
|
||||
<Button color="primary" onClick={() => setAccountTransferFlow(true)}>
|
||||
✨ Get your mobile device logged in ✨
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{accountTransferFlow ? (
|
||||
<Card className="w-full flex flex-col gap-4 items-center text-center">
|
||||
<CreateCrossDeviceAccountTransferAsSource />
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
<Button onClick={() => auth.signUp("supercoolusername")}>
|
||||
Sign up
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => auth.logIn()}>Log in with passkey</Button>
|
||||
|
||||
<Button color="primary" onClick={() => setAccountTransferFlow(true)}>
|
||||
✨ Use mobile device to log in ✨
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{accountTransferFlow ? (
|
||||
<Card className="w-full flex flex-col gap-4 items-center text-center">
|
||||
<CreateCrossDeviceAccountTransferAsTarget
|
||||
onLoggedIn={() => {
|
||||
console.log("logged in!");
|
||||
setAccountTransferFlow(false);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<p className="text-balance">
|
||||
You can also use the{" "}
|
||||
<span className="font-bold">Get your mobile device logged in</span>{" "}
|
||||
button on an already logged-in device
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Button } from "./Button";
|
||||
|
||||
export function BackToHomepageContainer({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p>{children}</p>
|
||||
|
||||
<a href="/">
|
||||
<Button color="primary">Back to homepage</Button>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
color?: "default" | "primary" | "destructive";
|
||||
}
|
||||
|
||||
const colors = {
|
||||
default: "bg-zinc-100 hover:bg-zinc-50 text-black border-zinc-200",
|
||||
primary: "bg-blue-600 hover:bg-blue-500 text-white border-blue-600",
|
||||
destructive: "bg-red-600 hover:bg-red-500 text-white border-red-600",
|
||||
};
|
||||
|
||||
export function Button({ color = "default", ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={`${colors[color]} transition-colors duration-75 ease-out border rounded-md py-1.5 px-3 text-sm`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function Card({ children, ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={`bg-white rounded-lg border border-zinc-200 p-4 shadow ${props.className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { OTPInput, REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ConfirmationCodeInputProps {
|
||||
onSubmit: (code: string) => void;
|
||||
}
|
||||
|
||||
export function ConfirmationCodeForm({ onSubmit }: ConfirmationCodeInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
return (
|
||||
<OTPInput
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onComplete={() => onSubmit(value)}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
maxLength={6}
|
||||
containerClassName="group flex items-center has-[:disabled]:opacity-30"
|
||||
render={({ slots }) => (
|
||||
<div className="flex">
|
||||
{slots.map((slot, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${slot.isActive ? "outline-4 outline-blue-500" : "outline-0 outline-blue-500/20"} relative w-10 h-14 text-[2rem] flex items-center justify-center transition-all duration-100 border-zinc-200 border-y border-r first:border-l first:rounded-l-md last:rounded-r-md group-hover:border-blue-500/20 group-focus-within:border-blue-500/20 outline`}
|
||||
>
|
||||
{slot.char !== null ? <div>{slot.char}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCreateAccountTransfer } from "jazz-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { QRCode } from "./QRCode";
|
||||
|
||||
export function CreateCrossDeviceAccountTransferAsSource() {
|
||||
const [link, setLink] = useState<string | undefined>();
|
||||
|
||||
const { status, createLink, confirmationCode } = useCreateAccountTransfer({
|
||||
as: "source",
|
||||
handlerPath: "/#/accept-account-transfer",
|
||||
});
|
||||
|
||||
const onCreateLink = () => createLink().then(setLink);
|
||||
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return (
|
||||
<Button color="primary" onClick={onCreateLink}>
|
||||
Create QR code
|
||||
</Button>
|
||||
);
|
||||
|
||||
case "waitingForHandler":
|
||||
return (
|
||||
<>
|
||||
<p>Scan QR code to get your mobile device logged in</p>
|
||||
|
||||
{link ? <QRCode url={link} /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
case "confirmationCodeGenerated":
|
||||
return (
|
||||
<>
|
||||
<p>Confirmation code:</p>
|
||||
|
||||
<p className="font-medium text-3xl tracking-widest">
|
||||
{confirmationCode ?? "empty"}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
case "confirmationCodeCorrect":
|
||||
return <p>Confirmed! Logging in...</p>;
|
||||
|
||||
case "authorized":
|
||||
return <p>Your device has been logged in!</p>;
|
||||
|
||||
case "confirmationCodeIncorrect":
|
||||
return (
|
||||
<>
|
||||
<p>Incorrect confirmation code</p>
|
||||
|
||||
<Button color="primary" onClick={onCreateLink}>
|
||||
Try again
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<>
|
||||
<p>Something went wrong</p>
|
||||
|
||||
<Button onClick={onCreateLink}>Try again</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case "cancelled":
|
||||
return (
|
||||
<>
|
||||
<p>Login cancelled</p>
|
||||
|
||||
<Button color="primary" onClick={onCreateLink}>
|
||||
Try again
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
const check: never = status;
|
||||
if (check) throw new Error(`Unhandled status: ${check}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCreateAccountTransfer } from "jazz-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { ConfirmationCodeForm } from "./ConfirmationCodeForm";
|
||||
import { QRCode } from "./QRCode";
|
||||
|
||||
interface CreateCrossDeviceAccountTransferAsTargetProps {
|
||||
onLoggedIn: () => void;
|
||||
}
|
||||
|
||||
export function CreateCrossDeviceAccountTransferAsTarget({
|
||||
onLoggedIn,
|
||||
}: CreateCrossDeviceAccountTransferAsTargetProps) {
|
||||
const [link, setLink] = useState<string | undefined>();
|
||||
|
||||
const { status, createLink, sendConfirmationCode } = useCreateAccountTransfer(
|
||||
{
|
||||
as: "target",
|
||||
handlerPath: "/#/share-current-account",
|
||||
onLoggedIn,
|
||||
},
|
||||
);
|
||||
|
||||
const onCreateLink = () => createLink().then(setLink);
|
||||
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return (
|
||||
<Button color="primary" onClick={onCreateLink}>
|
||||
Create QR code
|
||||
</Button>
|
||||
);
|
||||
|
||||
case "waitingForHandler":
|
||||
return (
|
||||
<>
|
||||
<p>Scan QR code to log in</p>
|
||||
|
||||
{link ? <QRCode url={link} /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
case "confirmationCodeRequired":
|
||||
return (
|
||||
<>
|
||||
<p>Enter the confirmation code displayed on your other device</p>
|
||||
|
||||
{sendConfirmationCode ? (
|
||||
<ConfirmationCodeForm onSubmit={sendConfirmationCode} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
case "confirmationCodePending":
|
||||
return <p>Confirming...</p>;
|
||||
|
||||
case "authorized":
|
||||
return <p>Logged in!</p>;
|
||||
|
||||
case "confirmationCodeIncorrect":
|
||||
return (
|
||||
<>
|
||||
<p>Incorrect confirmation code!</p>
|
||||
|
||||
<Button color="primary" onClick={onCreateLink}>
|
||||
Try again
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<>
|
||||
<p>Something went wrong</p>
|
||||
|
||||
<Button onClick={onCreateLink}>Try again</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case "cancelled":
|
||||
return (
|
||||
<>
|
||||
<p>Cancelled</p>
|
||||
|
||||
<Button onClick={onCreateLink}>Try again</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
const check: never = status;
|
||||
if (check) throw new Error(`Unhandled status: ${check}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function Logo() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 386 146"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-black w-48 mx-auto"
|
||||
>
|
||||
<path
|
||||
d="M176.725 33.865H188.275V22.7H176.725V33.865ZM164.9 129.4H172.875C182.72 129.4 188.275 123.9 188.275 114.22V43.6H176.725V109.545C176.725 115.65 173.975 118.51 167.925 118.51H164.9V129.4ZM245.298 53.28C241.613 45.47 233.363 41.95 222.748 41.95C208.998 41.95 200.748 48.44 197.888 58.615L208.613 61.915C210.648 55.315 216.368 52.565 222.638 52.565C231.933 52.565 235.673 56.415 236.058 64.61C226.433 65.93 216.643 67.195 209.768 69.23C200.583 72.145 195.743 77.865 195.743 86.83C195.743 96.51 202.673 104.65 215.818 104.65C225.443 104.65 232.318 101.35 237.213 94.365V103H247.388V66.425C247.388 61.475 247.168 57.185 245.298 53.28ZM217.853 95.245C210.483 95.245 207.128 91.34 207.128 86.72C207.128 82.045 210.593 79.515 215.323 77.92C220.328 76.435 226.983 75.5 235.948 74.18C235.893 76.93 235.673 80.725 234.738 83.475C233.418 89.25 227.643 95.245 217.853 95.245ZM251.22 103H301.545V92.715H269.535L303.195 45.47V43.6H254.3V53.885H284.935L251.22 101.185V103ZM304.815 103H355.14V92.715H323.13L356.79 45.47V43.6H307.895V53.885H338.53L304.815 101.185V103Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M136.179 44.8277C136.179 44.8277 136.179 44.8277 136.179 44.8276V21.168C117.931 28.5527 97.9854 32.6192 77.0897 32.6192C65.1466 32.6192 53.5138 31.2908 42.331 28.7737V51.4076C42.331 51.4076 42.331 51.4076 42.331 51.4076V81.1508C41.2955 80.4385 40.1568 79.8458 38.9405 79.3915C36.1732 78.358 33.128 78.0876 30.1902 78.6145C27.2524 79.1414 24.5539 80.4419 22.4358 82.3516C20.3178 84.2613 18.8754 86.6944 18.291 89.3433C17.7066 91.9921 18.0066 94.7377 19.1528 97.2329C20.2991 99.728 22.2403 101.861 24.7308 103.361C27.2214 104.862 30.1495 105.662 33.1448 105.662H33.1455C33.6061 105.662 33.8365 105.662 34.0314 105.659C44.5583 105.449 53.042 96.9656 53.2513 86.4386C53.2534 86.3306 53.2544 86.2116 53.2548 86.0486H53.2552V85.7149L53.2552 85.5521V82.0762L53.2552 53.1993C61.0533 54.2324 69.0092 54.7656 77.0897 54.7656C77.6696 54.7656 78.2489 54.7629 78.8276 54.7574V110.696C77.792 109.983 76.6533 109.391 75.437 108.936C72.6697 107.903 69.6246 107.632 66.6867 108.159C63.7489 108.686 61.0504 109.987 58.9323 111.896C56.8143 113.806 55.3719 116.239 54.7875 118.888C54.2032 121.537 54.5031 124.283 55.6494 126.778C56.7956 129.273 58.7368 131.405 61.2273 132.906C63.7179 134.406 66.646 135.207 69.6414 135.207C70.1024 135.207 70.3329 135.207 70.5279 135.203C81.0548 134.994 89.5385 126.51 89.7478 115.983C89.7517 115.788 89.7517 115.558 89.7517 115.097V111.621L89.7517 54.3266C101.962 53.4768 113.837 51.4075 125.255 48.2397V80.9017C124.219 80.1894 123.081 79.5966 121.864 79.1424C119.097 78.1089 116.052 77.8384 113.114 78.3653C110.176 78.8922 107.478 80.1927 105.36 82.1025C103.242 84.0122 101.799 86.4453 101.215 89.0941C100.631 91.743 100.931 94.4886 102.077 96.9837C103.223 99.4789 105.164 101.612 107.655 103.112C110.145 104.612 113.073 105.413 116.069 105.413C116.53 105.413 116.76 105.413 116.955 105.409C127.482 105.2 135.966 96.7164 136.175 86.1895C136.179 85.9945 136.179 85.764 136.179 85.3029V81.8271L136.179 44.8277Z"
|
||||
fill="#146AFF"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import QRCodeGenerator from "qrcode";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface QRCodeProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function QRCode({ url }: QRCodeProps) {
|
||||
const [qr, setQr] = useState<string>();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
QRCodeGenerator.toDataURL(url)
|
||||
.then(setQr)
|
||||
.catch((error) => console.error(error));
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{qr ? (
|
||||
<img
|
||||
src={qr}
|
||||
alt="QR Code"
|
||||
className="w-72 h-72 rounded-xl border-2 border-blue-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-96 h-96 bg-white rounded-lg flex items-center justify-center">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied to clipboard!" : "Copy link to clipboard"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useAcceptAccountTransfer } from "jazz-react";
|
||||
import { BackToHomepageContainer } from "../BackToHomepageContainer";
|
||||
|
||||
export default function CrossDeviceAccountTransferHandlerSource() {
|
||||
return (
|
||||
<main className="container flex flex-col items-center gap-4 px-4 py-8 text-center">
|
||||
<h1 className="text-xl">Account Transfer Source Handler</h1>
|
||||
<HandleAccountTransferAsSource />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function HandleAccountTransferAsSource() {
|
||||
const { status, confirmationCode } = useAcceptAccountTransfer({
|
||||
as: "source",
|
||||
handlerPath: "/#/share-current-account",
|
||||
});
|
||||
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return <p>Loading...</p>;
|
||||
|
||||
case "confirmationCodeGenerated":
|
||||
return (
|
||||
<>
|
||||
<p>Confirmation code:</p>
|
||||
<p className="font-medium text-3xl tracking-widest">
|
||||
{confirmationCode ?? "empty"}
|
||||
</p>
|
||||
<p className="text-red-600">Never share this code with anyone!</p>
|
||||
</>
|
||||
);
|
||||
|
||||
case "authorized":
|
||||
return (
|
||||
<BackToHomepageContainer>
|
||||
Your device has been logged in!
|
||||
</BackToHomepageContainer>
|
||||
);
|
||||
|
||||
case "confirmationCodeIncorrect":
|
||||
return (
|
||||
<>
|
||||
<p>Incorrect confirmation code</p>
|
||||
<p>Please try again</p>
|
||||
</>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<BackToHomepageContainer>Something went wrong</BackToHomepageContainer>
|
||||
);
|
||||
|
||||
case "cancelled":
|
||||
return <BackToHomepageContainer>Login cancelled</BackToHomepageContainer>;
|
||||
|
||||
default:
|
||||
const check: never = status;
|
||||
if (check) throw new Error(`Unhandled status: ${check}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useAcceptAccountTransfer } from "jazz-react";
|
||||
import { BackToHomepageContainer } from "../BackToHomepageContainer";
|
||||
import { ConfirmationCodeForm } from "../ConfirmationCodeForm";
|
||||
|
||||
export default function CrossDeviceAccountTransferHandlerTarget() {
|
||||
return (
|
||||
<main className="container flex flex-col items-center gap-4 px-4 py-8 text-center">
|
||||
<h1 className="text-xl">Account Transfer Target Handler</h1>
|
||||
<HandleAccountTransferAsTarget />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function HandleAccountTransferAsTarget() {
|
||||
const { status, sendConfirmationCode } = useAcceptAccountTransfer({
|
||||
as: "target",
|
||||
handlerPath: "/#/accept-account-transfer",
|
||||
onLoggedIn: () => {
|
||||
console.log("logged in!");
|
||||
},
|
||||
});
|
||||
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return <p>Loading...</p>;
|
||||
|
||||
case "confirmationCodeRequired":
|
||||
return (
|
||||
<>
|
||||
<p>Enter the confirmation code displayed on your other device</p>
|
||||
|
||||
{sendConfirmationCode ? (
|
||||
<ConfirmationCodeForm onSubmit={sendConfirmationCode} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
case "confirmationCodePending":
|
||||
return <p>Confirming...</p>;
|
||||
|
||||
case "authorized":
|
||||
return <BackToHomepageContainer>Logged in!</BackToHomepageContainer>;
|
||||
|
||||
case "confirmationCodeIncorrect":
|
||||
return (
|
||||
<>
|
||||
<p>Incorrect confirmation code!</p>
|
||||
<p>Please try again</p>
|
||||
</>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<BackToHomepageContainer>Something went wrong</BackToHomepageContainer>
|
||||
);
|
||||
|
||||
case "cancelled":
|
||||
return <BackToHomepageContainer>Login cancelled</BackToHomepageContainer>;
|
||||
|
||||
default:
|
||||
const check: never = status;
|
||||
if (check) throw new Error(`Unhandled status: ${check}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useIsAuthenticated } from "jazz-react";
|
||||
import { AuthButtons } from "../AuthButtons.tsx";
|
||||
import { Logo } from "../Logo.tsx";
|
||||
|
||||
export default function HomePage() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
<nav className="container flex flex-col items-center gap-8 py-8 px-4 text-center">
|
||||
<p className="text-lg">
|
||||
{isAuthenticated ? "You're logged in!" : "Sign up or log in"}
|
||||
</p>
|
||||
|
||||
<AuthButtons />
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="container flex flex-col gap-8">
|
||||
<Logo />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
examples/cross-device-account-transfer/src/index.css
Normal file
3
examples/cross-device-account-transfer/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
30
examples/cross-device-account-transfer/src/main.tsx
Normal file
30
examples/cross-device-account-transfer/src/main.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { JazzProvider } from "jazz-react";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import { apiKey } from "./apiKey.ts";
|
||||
import { JazzAccount } from "./schema.ts";
|
||||
|
||||
// We use this to identify the app in the passkey auth
|
||||
export const APPLICATION_NAME = "Cross-Device Account Transfer Example";
|
||||
|
||||
declare module "jazz-react" {
|
||||
export interface Register {
|
||||
Account: JazzAccount;
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<JazzProvider
|
||||
sync={{ peer: `wss://cloud.jazz.tools/?key=${apiKey}` }}
|
||||
AccountSchema={JazzAccount}
|
||||
>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</JazzProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
20
examples/cross-device-account-transfer/src/schema.ts
Normal file
20
examples/cross-device-account-transfer/src/schema.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Learn about schemas here:
|
||||
* https://jazz.tools/docs/react/schemas/covalues
|
||||
*/
|
||||
|
||||
import { Account, Group, Profile } from "jazz-tools";
|
||||
|
||||
export class JazzAccount extends Account {
|
||||
/** The account migration is run on account creation and on every log-in.
|
||||
* You can use it to set up the account root and any other initial CoValues you need.
|
||||
*/
|
||||
migrate(this: JazzAccount) {
|
||||
if (this.profile === undefined) {
|
||||
const group = Group.create();
|
||||
group.addMember("everyone", "reader"); // The profile info is visible to everyone
|
||||
|
||||
this.profile = Profile.create({ name: "supercoolusername" }, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
examples/cross-device-account-transfer/src/vite-env.d.ts
vendored
Normal file
1
examples/cross-device-account-transfer/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
23
examples/cross-device-account-transfer/tailwind.config.ts
Normal file
23
examples/cross-device-account-transfer/tailwind.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: {
|
||||
DEFAULT: "0.75rem",
|
||||
sm: "1rem",
|
||||
},
|
||||
screens: {
|
||||
lg: "600px",
|
||||
xl: "600px",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} as const;
|
||||
|
||||
export default config;
|
||||
24
examples/cross-device-account-transfer/tsconfig.app.json
Normal file
24
examples/cross-device-account-transfer/tsconfig.app.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
examples/cross-device-account-transfer/tsconfig.json
Normal file
7
examples/cross-device-account-transfer/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
examples/cross-device-account-transfer/tsconfig.node.json
Normal file
22
examples/cross-device-account-transfer/tsconfig.node.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
8
examples/cross-device-account-transfer/vercel.json
Normal file
8
examples/cross-device-account-transfer/vercel.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"build": {
|
||||
"env": {
|
||||
"APP_NAME": "cross-device-account-transfer"
|
||||
}
|
||||
},
|
||||
"ignoreCommand": "node ../../ignore-vercel-build.js"
|
||||
}
|
||||
7
examples/cross-device-account-transfer/vite.config.ts
Normal file
7
examples/cross-device-account-transfer/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"jazz-react": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.274.0",
|
||||
|
||||
@@ -6,12 +6,7 @@ import {
|
||||
} from "react-router-dom";
|
||||
import "./index.css";
|
||||
|
||||
import {
|
||||
JazzProvider,
|
||||
PassphraseAuthBasicUI,
|
||||
useAcceptInvite,
|
||||
useAccount,
|
||||
} from "jazz-react";
|
||||
import { JazzProvider, useAcceptInvite, useAccount } from "jazz-react";
|
||||
|
||||
import React from "react";
|
||||
import { TodoAccount, TodoProject } from "./1_schema.ts";
|
||||
@@ -23,8 +18,9 @@ import {
|
||||
ThemeProvider,
|
||||
TitleAndLogo,
|
||||
} from "./basicComponents/index.ts";
|
||||
import { AccountTransferLinkHandlerPage } from "./components/Auth/AccountTransferLinkHandlerPage";
|
||||
import { PasskeyAndCrossDeviceAccountTransferAuth } from "./components/Auth/PasskeyAndCrossDeviceAccountTransferAuth";
|
||||
import { TaskGenerator } from "./components/TaskGenerator.tsx";
|
||||
import { wordlist } from "./wordlist.ts";
|
||||
|
||||
/**
|
||||
* Walkthrough: The top-level provider `<JazzProvider/>`
|
||||
@@ -46,9 +42,9 @@ function JazzAndAuth({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
AccountSchema={TodoAccount}
|
||||
>
|
||||
<PassphraseAuthBasicUI appName={appName} wordlist={wordlist}>
|
||||
<PasskeyAndCrossDeviceAccountTransferAuth>
|
||||
{children}
|
||||
</PassphraseAuthBasicUI>
|
||||
</PasskeyAndCrossDeviceAccountTransferAuth>
|
||||
</JazzProvider>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +86,10 @@ export default function App() {
|
||||
path: "/invite/*",
|
||||
element: <p>Accepting invite...</p>,
|
||||
},
|
||||
{
|
||||
path: "/accept-account-transfer/:x/:y",
|
||||
element: <AccountTransferLinkHandlerPage />,
|
||||
},
|
||||
{
|
||||
path: "/generate",
|
||||
element: <TaskGenerator />,
|
||||
|
||||
69
examples/todo/src/basicComponents/ui/input-otp.tsx
Normal file
69
examples/todo/src/basicComponents/ui/input-otp.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { Dot } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils";
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = "InputOTP";
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = "InputOTPGroup";
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = "InputOTPSlot";
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Dot />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator";
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Button } from "@/basicComponents/ui/button";
|
||||
import { useAcceptAccountTransfer } from "jazz-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function AccountTransferLinkHandlerPage() {
|
||||
return (
|
||||
<main className="container flex flex-col items-center gap-4 px-4 py-8 text-center">
|
||||
<h1 className="text-2xl font-bold">Get your device logged in</h1>
|
||||
<div className="flex flex-col items-center gap-4 p-6">
|
||||
<AccountTransferHandler />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountTransferHandler() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { status, confirmationCode } = useAcceptAccountTransfer({
|
||||
as: "source",
|
||||
handlerPath: "/#/accept-account-transfer",
|
||||
});
|
||||
|
||||
const handleBackToHome = () => {
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return <p>Loading...</p>;
|
||||
|
||||
case "confirmationCodeGenerated":
|
||||
return (
|
||||
<>
|
||||
<p>Enter this code on your other device</p>
|
||||
<p className="font-medium text-3xl tracking-widest text-center">
|
||||
{confirmationCode ?? "empty"}
|
||||
</p>
|
||||
<p className="text-red-500">Never share this code with anyone!</p>
|
||||
</>
|
||||
);
|
||||
|
||||
case "authorized":
|
||||
return (
|
||||
<>
|
||||
<p>Your device has been logged in! 🚀</p>
|
||||
<Button onClick={handleBackToHome} className="w-full">
|
||||
Back to home
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case "confirmationCodeIncorrect":
|
||||
return (
|
||||
<>
|
||||
<p>The confirmation code was incorrect - please try again</p>
|
||||
<Button onClick={handleBackToHome} className="w-full">
|
||||
Back to home
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<>
|
||||
<p>Oops! Something went wrong</p>
|
||||
<Button onClick={handleBackToHome} className="w-full">
|
||||
Back to home
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case "cancelled":
|
||||
return (
|
||||
<>
|
||||
<p>The login process was cancelled</p>
|
||||
<Button onClick={handleBackToHome} className="w-full">
|
||||
Back to home
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
const check: never = status;
|
||||
if (check) throw new Error(`Unhandled status: ${check}`);
|
||||
}
|
||||
}
|
||||
112
examples/todo/src/components/Auth/CreateAccountTransferLink.tsx
Normal file
112
examples/todo/src/components/Auth/CreateAccountTransferLink.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Button } from "@/basicComponents/ui/button";
|
||||
import { useCreateAccountTransfer } from "jazz-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "../../basicComponents/ui/input-otp";
|
||||
import { QRCode } from "./QRCode";
|
||||
|
||||
interface CreateAccountTransferLinkProps {
|
||||
onLoggedIn: () => void;
|
||||
}
|
||||
|
||||
export function CreateAccountTransferLink({
|
||||
onLoggedIn,
|
||||
}: CreateAccountTransferLinkProps) {
|
||||
const [link, setLink] = useState<string | undefined>();
|
||||
const [confirmationCode, setConfirmationCode] = useState("");
|
||||
const createdLinkRef = useRef<boolean>(false);
|
||||
|
||||
const { status, createLink, sendConfirmationCode } = useCreateAccountTransfer(
|
||||
{
|
||||
as: "target",
|
||||
handlerPath: "/#/accept-account-transfer",
|
||||
onLoggedIn,
|
||||
},
|
||||
);
|
||||
|
||||
const onCreateLink = () => createLink().then(setLink);
|
||||
|
||||
useEffect(() => {
|
||||
if (createdLinkRef.current) return;
|
||||
createdLinkRef.current = true;
|
||||
onCreateLink();
|
||||
}, []);
|
||||
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return <p>Loading...</p>;
|
||||
|
||||
case "waitingForHandler":
|
||||
return (
|
||||
<>
|
||||
<p>Scan the QR code from your logged-in mobile device</p>
|
||||
{link ? <QRCode url={link} /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
case "confirmationCodeRequired":
|
||||
return (
|
||||
<>
|
||||
<p>Enter the code displayed on your mobile device</p>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={confirmationCode}
|
||||
onChange={(value) => setConfirmationCode(value)}
|
||||
onComplete={() => sendConfirmationCode?.(confirmationCode)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</>
|
||||
);
|
||||
|
||||
case "confirmationCodePending":
|
||||
return <p>Checking code...</p>;
|
||||
|
||||
case "authorized":
|
||||
return <p>Authorized! 🚀</p>;
|
||||
|
||||
case "confirmationCodeIncorrect":
|
||||
return (
|
||||
<>
|
||||
<p>Incorrect code - please try again</p>
|
||||
<Button onClick={onCreateLink} className="w-full">
|
||||
Try again
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<>
|
||||
<p>Oops! Something went wrong</p>
|
||||
<Button onClick={onCreateLink} className="w-full">
|
||||
Try again
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case "cancelled":
|
||||
return (
|
||||
<>
|
||||
<p>Authentication cancelled</p>
|
||||
<Button onClick={onCreateLink} className="w-full">
|
||||
Try again
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
const check: never = status;
|
||||
if (check) throw new Error(`Unhandled status: ${check}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Button } from "@/basicComponents/ui/button";
|
||||
import { useIsAuthenticated, usePasskeyAuth } from "jazz-react";
|
||||
import { KeyRoundIcon, QrCodeIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CreateAccountTransferLink } from "./CreateAccountTransferLink";
|
||||
|
||||
export const PasskeyAndCrossDeviceAccountTransferAuth = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const [username, setUsername] = useState("");
|
||||
const [isSignUp, setIsSignUp] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authMethod, setAuthMethod] = useState<
|
||||
"passkey" | "auth-transfer" | null
|
||||
>(null);
|
||||
|
||||
const passkeyAuth = usePasskeyAuth({
|
||||
appName: "Jazz Todo App",
|
||||
});
|
||||
|
||||
const handleViewChange = () => {
|
||||
setIsSignUp(!isSignUp);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSignUp = async () => {
|
||||
try {
|
||||
await passkeyAuth.signUp(username);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "Unknown error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
await passkeyAuth.logIn();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "Unknown error");
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated) return children;
|
||||
|
||||
if (authMethod === "auth-transfer") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center max-w-md mx-auto p-6 text-center">
|
||||
<h2 className="text-2xl font-bold">Sign in with mobile device</h2>
|
||||
<CreateAccountTransferLink onLoggedIn={() => setAuthMethod(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 p-6 text-center max-w-md mx-auto">
|
||||
<h2 className="text-2xl font-bold">
|
||||
{isSignUp ? "Create account" : "Welcome back"}
|
||||
</h2>
|
||||
<p>Sign {isSignUp ? "up" : "in"} to access your todos</p>
|
||||
|
||||
{isSignUp && (
|
||||
<div className="w-full flex flex-col items-start">
|
||||
<label htmlFor="username" className="block text-sm font-medium">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <div className="text-sm">{error}</div> : null}
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<Button
|
||||
onClick={isSignUp ? handleSignUp : handleLogin}
|
||||
className="w-full"
|
||||
>
|
||||
<KeyRoundIcon className="w-4 h-4 mr-2" />
|
||||
{isSignUp ? "Sign up" : "Login"} with passkey
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setAuthMethod("auth-transfer")}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<QrCodeIcon className="w-4 h-4 mr-2" />
|
||||
Sign in with mobile device
|
||||
</Button>
|
||||
|
||||
<div className="text-sm">
|
||||
{isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
|
||||
<Button type="button" onClick={handleViewChange} variant="link">
|
||||
{isSignUp ? "Login" : "Sign up"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
35
examples/todo/src/components/Auth/QRCode.tsx
Normal file
35
examples/todo/src/components/Auth/QRCode.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Button } from "@/basicComponents";
|
||||
import QRCodeGenerator from "qrcode";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface QRCodeProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function QRCode({ url }: QRCodeProps) {
|
||||
const [qr, setQr] = useState<string | undefined>();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setQr(undefined);
|
||||
QRCodeGenerator.toDataURL(url)
|
||||
.then(setQr)
|
||||
.catch((error) => console.error(error));
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{qr ? <img src={qr} alt="QR Code" className="w-60 h-60" /> : null}
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy link"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
export const metadata = { title: "Cross-Device Account Transfer" };
|
||||
|
||||
import { CodeGroup, ContentByFramework } from "@/components/forMdx";
|
||||
|
||||
# Cross-Device Account Transfer
|
||||
|
||||
Cross-Device Account Transfer can be used to transfer authentication from one device to another, for example using a QR code.
|
||||
|
||||
## How it works
|
||||
|
||||
Cross-Device Account Transfer supports two flows, where **Desktop** refers to the device that creates and displays the QR code, and **Mobile** refers to the device that scans and handles it:
|
||||
|
||||
- **Mobile to Desktop**: Mobile scans the QR code to authenticate the desktop device
|
||||
- **Desktop to Mobile**: Mobile scans the QR to authenticate itself
|
||||
|
||||
Once the QR code has been scanned, the already-authenticated `source` device shows a 6-digit code that the user should enter on the `target` device trying to authenticate.
|
||||
|
||||
If the confirmation code is correct, the source device reveals the secret, and the target device uses it to authenticate.
|
||||
|
||||
You can pick a flow that suits your use case, or use both!
|
||||
|
||||
## Key benefits
|
||||
|
||||
- **Portable**: Apps using passkey auth for example can use cross-device account transfer links to authenticate devices where the passkey is not available
|
||||
- **User-friendly**: Users familiar with WhatsApp, Discord, etc. already know how this works
|
||||
|
||||
## Implementation
|
||||
|
||||
<ContentByFramework framework="react">
|
||||
### Mobile to Desktop
|
||||
|
||||
We start by creating a link on the **Desktop** device using `useCreateAccountTransfer` as `target`, and handling the flow's `status`:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import { useCreateAccountTransfer } from "jazz-react";
|
||||
import React, { useState } from "react";
|
||||
interface CreateAccountTransferAsTargetProps {
|
||||
onLoggedIn: () => void;
|
||||
}
|
||||
const QRCode = (_: { url: string }) => (<></>)
|
||||
const ConfirmationCodeForm = (_: { onSubmit: (code: string) => void}) => (<></>)
|
||||
|
||||
|
||||
// ---cut---
|
||||
export function CreateAccountTransferAsTarget({
|
||||
onLoggedIn,
|
||||
}: CreateAccountTransferAsTargetProps) {
|
||||
const [link, setLink] = useState<string | undefined>();
|
||||
|
||||
const { status, createLink, sendConfirmationCode } = useCreateAccountTransfer({
|
||||
as: "target",
|
||||
onLoggedIn,
|
||||
});
|
||||
|
||||
// Handle the status
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
[Full example](https://github.com/garden-co/jazz/blob/main/examples/cross-device-account-transfer/src/components/CreateCrossDeviceAccountTransferAsTarget.tsx)
|
||||
|
||||
On the **Mobile** side, we need to host a page at `#/accept-account-transfer`, or provide an alternative handler path to the hooks using the `handlerPath` option.
|
||||
|
||||
This is the page we'll land on when the QR code is scanned, handling the link with the `useAcceptAccountTransfer` hook as `source`, and using `status` to display the current state:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import React from "react";
|
||||
import { useAcceptAccountTransfer } from "jazz-react";
|
||||
|
||||
// ---cut---
|
||||
export function HandleAccountTransferAsSource() {
|
||||
const { status, confirmationCode } = useAcceptAccountTransfer({
|
||||
as: "source",
|
||||
});
|
||||
|
||||
// Handle the status
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
[Full example](https://github.com/garden-co/jazz/blob/main/examples/cross-device-account-transfer/src/components/pages/CrossDeviceAccountTransferHandlerSource.tsx)
|
||||
|
||||
### Desktop to Mobile
|
||||
|
||||
This is similar to the **Mobile to Desktop** flow, but the roles are reversed: `useCreateAccountTransfer` on the **Desktop** device should be used as `source`, and `useAcceptAccountTransfer` on the **Mobile** device should be used as `target`.
|
||||
|
||||
On the **Desktop** device:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import React, { useState } from "react";
|
||||
import { useCreateAccountTransfer } from "jazz-react";
|
||||
const QRCode = (_: { url: string }) => (<></>)
|
||||
|
||||
// ---cut---
|
||||
export function CreateAccountTransferAsSource() {
|
||||
const [link, setLink] = useState<string | undefined>();
|
||||
|
||||
const { status, createLink, confirmationCode } = useCreateAccountTransfer({
|
||||
as: "source",
|
||||
});
|
||||
|
||||
// Handle the status
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
[Full example](https://github.com/garden-co/jazz/blob/main/examples/cross-device-account-transfer/src/components/CreateCrossDeviceAccountTransferAsSource.tsx)
|
||||
|
||||
On the **Mobile** device we need to host a page at `#/accept-account-transfer`, or provide an alternative handler path to the hooks using the `handlerPath` option.
|
||||
|
||||
This is the page we'll land on when the QR code is scanned, handling the link with the `useAcceptAccountTransfer` hook as `target`, and using `status` to display the current state:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import React from "react";
|
||||
import { useAcceptAccountTransfer } from "jazz-react";
|
||||
const ConfirmationCodeForm = (_: { onSubmit: (code: string) => void}) => (<></>)
|
||||
|
||||
// ---cut---
|
||||
export function HandleAccountTransferAsTarget() {
|
||||
const { status, sendConfirmationCode } = useAcceptAccountTransfer({
|
||||
as: "target",
|
||||
handlerPath: "/#/accept-account-transfer",
|
||||
onLoggedIn: () => {
|
||||
console.log("logged in!");
|
||||
},
|
||||
});
|
||||
|
||||
// Handle the status
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
[Full example](https://github.com/garden-co/jazz/blob/main/examples/cross-device-account-transfer/src/components/pages/CrossDeviceAccountTransferHandlerTarget.tsx)
|
||||
</ContentByFramework>
|
||||
|
||||
## Examples
|
||||
|
||||
- [cross-device-account-transfer](https://cross-device-account-transfer-demo.jazz.tools/): Both flows implemented to demonstrate how everything works - [code](https://github.com/garden-co/jazz/blob/main/examples/cross-device-account-transfer)
|
||||
- [todo](https://todo-demo.jazz.tools/): Mobile to Desktop flow implemented as a more practical demo - [code](https://github.com/garden-co/jazz/blob/main/examples/todo)
|
||||
|
||||
## When to use Cross-Device Account Transfer
|
||||
|
||||
Ideal when:
|
||||
|
||||
- You need a fallback authentication method alongside passkeys
|
||||
- Your app is designed to be used on mobile devices
|
||||
|
||||
## Limitations and considerations
|
||||
|
||||
- **No revocation**: Once the secret has been shared with the target device, it cannot be revoked
|
||||
- **Requires a camera**: The link handler needs to have a camera to scan the QR code, or another way to get the link
|
||||
@@ -276,6 +276,11 @@ export const docNavigationItems = [
|
||||
href: "/docs/authentication/clerk",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "Cross-Device Account Transfer",
|
||||
href: "/docs/authentication/cross-device-account-transfer",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "Writing your own",
|
||||
href: "/docs/authentication/writing-your-own",
|
||||
|
||||
@@ -9,10 +9,12 @@ export function connectedPeers(
|
||||
peer1role = "client",
|
||||
peer2role = "client",
|
||||
crashOnClose = false,
|
||||
deletePeerStateOnClose = true,
|
||||
}: {
|
||||
peer1role?: Peer["role"];
|
||||
peer2role?: Peer["role"];
|
||||
crashOnClose?: boolean;
|
||||
deletePeerStateOnClose?: boolean;
|
||||
} = {},
|
||||
): [Peer, Peer] {
|
||||
const [from1to2Rx, from1to2Tx] = newQueuePair();
|
||||
@@ -24,6 +26,7 @@ export function connectedPeers(
|
||||
outgoing: from1to2Tx,
|
||||
role: peer2role,
|
||||
crashOnClose: crashOnClose,
|
||||
deletePeerStateOnClose,
|
||||
};
|
||||
|
||||
const peer1AsPeer: Peer = {
|
||||
@@ -32,6 +35,7 @@ export function connectedPeers(
|
||||
outgoing: from2to1Tx,
|
||||
role: peer1role,
|
||||
crashOnClose: crashOnClose,
|
||||
deletePeerStateOnClose,
|
||||
};
|
||||
|
||||
return [peer1AsPeer, peer2AsPeer];
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function createTwoConnectedNodes(
|
||||
{
|
||||
peer1role: node2Role,
|
||||
peer2role: node1Role,
|
||||
deletePeerStateOnClose: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -624,6 +625,7 @@ export function connectedPeersWithMessagesTracking(opts: {
|
||||
const [peer1, peer2] = connectedPeers(opts.peer1.id, opts.peer2.id, {
|
||||
peer1role: opts.peer1.role,
|
||||
peer2role: opts.peer2.role,
|
||||
deletePeerStateOnClose: false,
|
||||
});
|
||||
|
||||
const peer1Push = peer1.outgoing.push;
|
||||
|
||||
229
packages/jazz-react-core/src/auth/CrossDeviceAccountTransfer.tsx
Normal file
229
packages/jazz-react-core/src/auth/CrossDeviceAccountTransfer.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
CrossDeviceAccountTransfer,
|
||||
CrossDeviceAccountTransferAsSourceOptions,
|
||||
CrossDeviceAccountTransferAsTargetOptions,
|
||||
CrossDeviceAccountTransferCreateAsSource,
|
||||
CrossDeviceAccountTransferCreateAsTarget,
|
||||
CrossDeviceAccountTransferHandleAsSource,
|
||||
CrossDeviceAccountTransferHandleAsTarget,
|
||||
CrossDeviceAccountTransferOptions,
|
||||
} from "jazz-tools";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useAuthSecretStorage, useJazzContext } from "../hooks.js";
|
||||
|
||||
const DEFAULT_EXPIRE_IN_MS = 15 * 60 * 1000;
|
||||
|
||||
export type UseCrossDeviceAccountTransferAsSourceOptions =
|
||||
CrossDeviceAccountTransferOptions & CrossDeviceAccountTransferAsSourceOptions;
|
||||
|
||||
export type UseCrossDeviceAccountTransferAsTargetOptions =
|
||||
CrossDeviceAccountTransferOptions & CrossDeviceAccountTransferAsTargetOptions;
|
||||
|
||||
export function useCreateAccountTransferAsSource(
|
||||
origin: string,
|
||||
{
|
||||
expireInMs = DEFAULT_EXPIRE_IN_MS,
|
||||
...options
|
||||
}: Partial<UseCrossDeviceAccountTransferAsSourceOptions> = {},
|
||||
) {
|
||||
const context = useJazzContext();
|
||||
const authSecretStorage = useAuthSecretStorage();
|
||||
|
||||
const crossDeviceAccountTransfer = useMemo(() => {
|
||||
return new CrossDeviceAccountTransferCreateAsSource(
|
||||
new CrossDeviceAccountTransfer(
|
||||
context.node.crypto,
|
||||
context.authenticate,
|
||||
authSecretStorage,
|
||||
origin,
|
||||
options,
|
||||
),
|
||||
);
|
||||
}, [origin]);
|
||||
|
||||
const authState = useSyncExternalStore(
|
||||
useCallback(crossDeviceAccountTransfer.subscribe, [
|
||||
crossDeviceAccountTransfer,
|
||||
]),
|
||||
() => crossDeviceAccountTransfer.authState,
|
||||
);
|
||||
|
||||
if ("guest" in context) {
|
||||
throw new Error(
|
||||
"Cross-Device Account Transfer is not supported in guest mode",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...authState,
|
||||
createLink: () => crossDeviceAccountTransfer.createLink(),
|
||||
cancelFlow: () => crossDeviceAccountTransfer.cancelFlow(),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function useCreateAccountTransferAsTarget(
|
||||
origin: string,
|
||||
{
|
||||
handlerTimeout = 30 * 1000,
|
||||
onLoggedIn,
|
||||
...options
|
||||
}: Partial<UseCrossDeviceAccountTransferAsTargetOptions> = {},
|
||||
) {
|
||||
const context = useJazzContext();
|
||||
const authSecretStorage = useAuthSecretStorage();
|
||||
|
||||
const onLoggedInRef = useRef(onLoggedIn);
|
||||
onLoggedInRef.current = onLoggedIn;
|
||||
|
||||
const crossDeviceAccountTransfer = useMemo(() => {
|
||||
const onLoggedIn = () => {
|
||||
onLoggedInRef.current?.();
|
||||
};
|
||||
|
||||
return new CrossDeviceAccountTransferCreateAsTarget(
|
||||
new CrossDeviceAccountTransfer(
|
||||
context.node.crypto,
|
||||
context.authenticate,
|
||||
authSecretStorage,
|
||||
origin,
|
||||
options,
|
||||
),
|
||||
{ handlerTimeout, onLoggedIn },
|
||||
);
|
||||
}, [origin]);
|
||||
|
||||
const authState = useSyncExternalStore(
|
||||
useCallback(crossDeviceAccountTransfer.subscribe, [
|
||||
crossDeviceAccountTransfer,
|
||||
]),
|
||||
() => crossDeviceAccountTransfer.authState,
|
||||
);
|
||||
|
||||
if ("guest" in context) {
|
||||
throw new Error(
|
||||
"Cross-Device Account Transfer is not supported in guest mode",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...authState,
|
||||
createLink: () => crossDeviceAccountTransfer.createLink(),
|
||||
cancelFlow: () => crossDeviceAccountTransfer.cancelFlow(),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function useAcceptAccountTransferAsTarget(
|
||||
origin: string,
|
||||
url: string,
|
||||
{
|
||||
handlerTimeout = 30 * 1000,
|
||||
onLoggedIn,
|
||||
...options
|
||||
}: Partial<UseCrossDeviceAccountTransferAsTargetOptions> = {},
|
||||
) {
|
||||
const context = useJazzContext();
|
||||
const authSecretStorage = useAuthSecretStorage();
|
||||
const urlRef = useRef<string | undefined>();
|
||||
|
||||
const onLoggedInRef = useRef(onLoggedIn);
|
||||
onLoggedInRef.current = onLoggedIn;
|
||||
|
||||
const crossDeviceAccountTransfer = useMemo(() => {
|
||||
const onLoggedIn = () => {
|
||||
onLoggedInRef.current?.();
|
||||
};
|
||||
|
||||
return new CrossDeviceAccountTransferHandleAsTarget(
|
||||
new CrossDeviceAccountTransfer(
|
||||
context.node.crypto,
|
||||
context.authenticate,
|
||||
authSecretStorage,
|
||||
origin,
|
||||
options,
|
||||
),
|
||||
{ handlerTimeout, onLoggedIn },
|
||||
);
|
||||
}, [origin]);
|
||||
|
||||
const authState = useSyncExternalStore(
|
||||
useCallback(crossDeviceAccountTransfer.subscribe, [
|
||||
crossDeviceAccountTransfer,
|
||||
]),
|
||||
() => crossDeviceAccountTransfer.authState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!crossDeviceAccountTransfer.checkValidUrl(url)) return;
|
||||
|
||||
if (urlRef.current === url) return;
|
||||
urlRef.current = url;
|
||||
|
||||
crossDeviceAccountTransfer.handleFlow(url);
|
||||
|
||||
return () => {
|
||||
crossDeviceAccountTransfer.cancelFlow();
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return {
|
||||
...authState,
|
||||
cancelFlow: () => crossDeviceAccountTransfer.cancelFlow(),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function useAcceptAccountTransferAsSource(
|
||||
origin: string,
|
||||
url: string,
|
||||
{
|
||||
expireInMs = DEFAULT_EXPIRE_IN_MS,
|
||||
...options
|
||||
}: Partial<UseCrossDeviceAccountTransferAsSourceOptions> = {},
|
||||
) {
|
||||
const context = useJazzContext();
|
||||
const authSecretStorage = useAuthSecretStorage();
|
||||
const urlRef = useRef<string | undefined>();
|
||||
|
||||
const crossDeviceAccountTransfer = useMemo(() => {
|
||||
return new CrossDeviceAccountTransferHandleAsSource(
|
||||
new CrossDeviceAccountTransfer(
|
||||
context.node.crypto,
|
||||
context.authenticate,
|
||||
authSecretStorage,
|
||||
origin,
|
||||
options,
|
||||
),
|
||||
{ expireInMs },
|
||||
);
|
||||
}, [origin]);
|
||||
|
||||
const authState = useSyncExternalStore(
|
||||
useCallback(crossDeviceAccountTransfer.subscribe, [
|
||||
crossDeviceAccountTransfer,
|
||||
]),
|
||||
() => crossDeviceAccountTransfer.authState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!crossDeviceAccountTransfer.checkValidUrl(url)) return;
|
||||
|
||||
if (urlRef.current === url) return;
|
||||
urlRef.current = url;
|
||||
|
||||
crossDeviceAccountTransfer.handleFlow(url);
|
||||
|
||||
return () => {
|
||||
crossDeviceAccountTransfer.cancelFlow();
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return {
|
||||
...authState,
|
||||
cancelFlow: () => crossDeviceAccountTransfer.cancelFlow(),
|
||||
} as const;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./DemoAuth.js";
|
||||
export * from "./PassphraseAuth.js";
|
||||
export * from "./CrossDeviceAccountTransfer.js";
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
useAcceptAccountTransferAsSource,
|
||||
useAcceptAccountTransferAsTarget,
|
||||
useCreateAccountTransferAsSource,
|
||||
useCreateAccountTransferAsTarget,
|
||||
} from "../auth/CrossDeviceAccountTransfer.js";
|
||||
import {
|
||||
createJazzTestAccount,
|
||||
createJazzTestGuest,
|
||||
setupJazzTestSync,
|
||||
} from "../testing";
|
||||
import { act, renderHook, waitFor } from "./testUtils";
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupJazzTestSync();
|
||||
});
|
||||
|
||||
describe("CrossDeviceAccountTransfer", () => {
|
||||
describe("useCreateAccountTransferAsProvider", () => {
|
||||
beforeEach(async () => {
|
||||
await createJazzTestAccount({ isCurrentActiveAccount: true });
|
||||
});
|
||||
|
||||
it("throws error when using guest account", async () => {
|
||||
const guestAccount = await createJazzTestGuest();
|
||||
|
||||
expect(() =>
|
||||
renderHook(
|
||||
() => useCreateAccountTransferAsSource(window.location.origin),
|
||||
{ account: guestAccount },
|
||||
),
|
||||
).toThrowError(
|
||||
"Cross-Device Account Transfer is not supported in guest mode",
|
||||
);
|
||||
});
|
||||
|
||||
it("initializes with idle state", async () => {
|
||||
const account = await createJazzTestAccount({});
|
||||
|
||||
const { result: createAsProvider } = renderHook(
|
||||
() => useCreateAccountTransferAsSource(window.location.origin),
|
||||
{ account },
|
||||
);
|
||||
|
||||
expect(createAsProvider.current.status).toBe("idle");
|
||||
expect(createAsProvider.current.createLink).toBeTypeOf("function");
|
||||
expect(createAsProvider.current.confirmationCode).toBeUndefined();
|
||||
createAsProvider.current.cancelFlow();
|
||||
});
|
||||
|
||||
it("can create a link and cancel flow", async () => {
|
||||
const account = await createJazzTestAccount({});
|
||||
const { result: createAsProvider } = renderHook(
|
||||
() => useCreateAccountTransferAsSource(window.location.origin),
|
||||
{ account },
|
||||
);
|
||||
let link = "";
|
||||
await act(async () => {
|
||||
link = await createAsProvider.current.createLink();
|
||||
});
|
||||
expect(link).toMatch(
|
||||
/^http:\/\/localhost:3000\/accept-account-transfer\/co_[^/]+\/inviteSecret_[^/]+$/,
|
||||
);
|
||||
expect(createAsProvider.current.status).toBe("waitingForHandler");
|
||||
act(() => {
|
||||
createAsProvider.current.cancelFlow();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(createAsProvider.current.status).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCreateAccountTransferAsConsumer", () => {
|
||||
beforeEach(async () => {
|
||||
await createJazzTestAccount({ isCurrentActiveAccount: true });
|
||||
});
|
||||
|
||||
it("throws error when using guest account", async () => {
|
||||
const guestAccount = await createJazzTestGuest();
|
||||
expect(() =>
|
||||
renderHook(
|
||||
() => useCreateAccountTransferAsTarget(window.location.origin),
|
||||
{ account: guestAccount },
|
||||
),
|
||||
).toThrowError(
|
||||
"Cross-Device Account Transfer is not supported in guest mode",
|
||||
);
|
||||
});
|
||||
|
||||
it("initializes with idle state", async () => {
|
||||
const { result: createAsConsumer } = renderHook(() =>
|
||||
useCreateAccountTransferAsTarget(window.location.origin),
|
||||
);
|
||||
expect(createAsConsumer.current.status).toBe("idle");
|
||||
expect(createAsConsumer.current.createLink).toBeTypeOf("function");
|
||||
expect(createAsConsumer.current.sendConfirmationCode).toBeUndefined();
|
||||
createAsConsumer.current.cancelFlow();
|
||||
});
|
||||
|
||||
it("can create a link and cancel flow", async () => {
|
||||
const account = await createJazzTestAccount({});
|
||||
const { result: createAsConsumer } = renderHook(
|
||||
() => useCreateAccountTransferAsTarget(window.location.origin),
|
||||
{ account },
|
||||
);
|
||||
let link = "";
|
||||
await act(async () => {
|
||||
link = await createAsConsumer.current.createLink();
|
||||
});
|
||||
expect(link).toMatch(
|
||||
/^http:\/\/localhost:3000\/accept-account-transfer\/co_[^/]+\/inviteSecret_[^/]+$/,
|
||||
);
|
||||
expect(createAsConsumer.current.status).toBe("waitingForHandler");
|
||||
act(() => {
|
||||
createAsConsumer.current.cancelFlow();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(createAsConsumer.current.status).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAcceptAccountTransferAsProvider", () => {
|
||||
beforeEach(async () => {
|
||||
await createJazzTestAccount({ isCurrentActiveAccount: true });
|
||||
});
|
||||
|
||||
it("initializes with idle state", async () => {
|
||||
const account = await createJazzTestAccount({});
|
||||
const { result: handleAsProvider } = renderHook(
|
||||
() =>
|
||||
useAcceptAccountTransferAsSource(
|
||||
window.location.origin,
|
||||
"invalid-link-gets-ignored",
|
||||
),
|
||||
{ account },
|
||||
);
|
||||
expect(handleAsProvider.current.status).toBe("idle");
|
||||
expect(handleAsProvider.current.confirmationCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles the flow", async () => {
|
||||
// Create consumer
|
||||
const { result: createAsConsumer } = renderHook(() =>
|
||||
useCreateAccountTransferAsTarget(window.location.origin),
|
||||
);
|
||||
let link = "";
|
||||
await act(async () => {
|
||||
link = await createAsConsumer.current.createLink();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(createAsConsumer.current.status).toBe("waitingForHandler");
|
||||
});
|
||||
|
||||
// Create provider
|
||||
const account = await createJazzTestAccount({});
|
||||
const { result: handleAsProvider } = renderHook(
|
||||
() => useAcceptAccountTransferAsSource(window.location.origin, link),
|
||||
{ account },
|
||||
);
|
||||
|
||||
// Confirmation code should be generated by the provider, and the consumer should be waiting for it
|
||||
await waitFor(() => {
|
||||
expect(handleAsProvider.current.status).toBe(
|
||||
"confirmationCodeGenerated",
|
||||
);
|
||||
});
|
||||
expect(handleAsProvider.current.confirmationCode).toBeDefined();
|
||||
expect(createAsConsumer.current.status).toBe("confirmationCodeRequired");
|
||||
expect(createAsConsumer.current.sendConfirmationCode).toBeDefined();
|
||||
|
||||
// The consumer should send the confirmation code to the provider
|
||||
await act(async () => {
|
||||
createAsConsumer.current.sendConfirmationCode!(
|
||||
handleAsProvider.current.confirmationCode!,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(createAsConsumer.current.status).toBe("authorized");
|
||||
});
|
||||
expect(handleAsProvider.current.status).toBe("authorized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAcceptAccountTransferAsConsumer", () => {
|
||||
beforeEach(async () => {
|
||||
await createJazzTestAccount({ isCurrentActiveAccount: true });
|
||||
});
|
||||
|
||||
it("initializes with idle state", async () => {
|
||||
const { result: handleAsConsumer } = renderHook(() =>
|
||||
useAcceptAccountTransferAsTarget(
|
||||
window.location.origin,
|
||||
"invalid-link-gets-ignored",
|
||||
),
|
||||
);
|
||||
expect(handleAsConsumer.current.status).toBe("idle");
|
||||
expect(handleAsConsumer.current.sendConfirmationCode).toBeNull();
|
||||
});
|
||||
|
||||
it("handles the flow", async () => {
|
||||
// Create consumer
|
||||
const { result: createAsConsumer } = renderHook(() =>
|
||||
useCreateAccountTransferAsTarget(window.location.origin),
|
||||
);
|
||||
let link = "";
|
||||
await act(async () => {
|
||||
link = await createAsConsumer.current.createLink();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(createAsConsumer.current.status).toBe("waitingForHandler");
|
||||
});
|
||||
|
||||
// Create provider
|
||||
const account = await createJazzTestAccount({});
|
||||
const { result: handleAsProvider } = renderHook(
|
||||
() => useAcceptAccountTransferAsSource(window.location.origin, link),
|
||||
{ account },
|
||||
);
|
||||
|
||||
// Confirmation code should be generated by the provider, and the consumer should be waiting for it
|
||||
await waitFor(() => {
|
||||
expect(handleAsProvider.current.status).toBe(
|
||||
"confirmationCodeGenerated",
|
||||
);
|
||||
});
|
||||
expect(handleAsProvider.current.confirmationCode).toBeDefined();
|
||||
expect(createAsConsumer.current.status).toBe("confirmationCodeRequired");
|
||||
expect(createAsConsumer.current.sendConfirmationCode).toBeDefined();
|
||||
|
||||
// The consumer should send the confirmation code to the provider
|
||||
await act(async () => {
|
||||
createAsConsumer.current.sendConfirmationCode!(
|
||||
handleAsProvider.current.confirmationCode!,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(createAsConsumer.current.status).toBe("authorized");
|
||||
});
|
||||
expect(handleAsProvider.current.status).toBe("authorized");
|
||||
});
|
||||
});
|
||||
});
|
||||
87
packages/jazz-react/src/auth/CrossDeviceAccountTransfer.tsx
Normal file
87
packages/jazz-react/src/auth/CrossDeviceAccountTransfer.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
type UseCrossDeviceAccountTransferAsSourceOptions,
|
||||
type UseCrossDeviceAccountTransferAsTargetOptions,
|
||||
useAcceptAccountTransferAsSource,
|
||||
useAcceptAccountTransferAsTarget,
|
||||
useCreateAccountTransferAsSource,
|
||||
useCreateAccountTransferAsTarget,
|
||||
} from "jazz-react-core";
|
||||
import { useRef } from "react";
|
||||
|
||||
export function useCreateAccountTransfer(
|
||||
options: {
|
||||
as: "source";
|
||||
} & Partial<UseCrossDeviceAccountTransferAsSourceOptions>,
|
||||
): ReturnType<typeof useCreateAccountTransferAsSource>;
|
||||
export function useCreateAccountTransfer(
|
||||
options: {
|
||||
as: "target";
|
||||
} & Partial<UseCrossDeviceAccountTransferAsTargetOptions>,
|
||||
): ReturnType<typeof useCreateAccountTransferAsTarget>;
|
||||
export function useCreateAccountTransfer({
|
||||
as: mode,
|
||||
...options
|
||||
}:
|
||||
| ({
|
||||
as: "source";
|
||||
} & Partial<UseCrossDeviceAccountTransferAsSourceOptions>)
|
||||
| ({
|
||||
as: "target";
|
||||
} & Partial<UseCrossDeviceAccountTransferAsTargetOptions>)) {
|
||||
const initialMode = useRef(mode);
|
||||
|
||||
if (initialMode.current !== mode) {
|
||||
console.warn(
|
||||
"useCreateAccountTransfer mode cannot be changed once mounted.",
|
||||
);
|
||||
}
|
||||
|
||||
if (initialMode.current === "source") {
|
||||
return useCreateAccountTransferAsSource(window.location.origin, options);
|
||||
} else {
|
||||
return useCreateAccountTransferAsTarget(window.location.origin, options);
|
||||
}
|
||||
}
|
||||
|
||||
export function useAcceptAccountTransfer(
|
||||
options: {
|
||||
as: "source";
|
||||
} & Partial<UseCrossDeviceAccountTransferAsSourceOptions>,
|
||||
): ReturnType<typeof useAcceptAccountTransferAsSource>;
|
||||
export function useAcceptAccountTransfer(
|
||||
options: {
|
||||
as: "target";
|
||||
} & Partial<UseCrossDeviceAccountTransferAsTargetOptions>,
|
||||
): ReturnType<typeof useAcceptAccountTransferAsTarget>;
|
||||
export function useAcceptAccountTransfer({
|
||||
as: mode,
|
||||
...options
|
||||
}:
|
||||
| ({
|
||||
as: "source";
|
||||
} & Partial<UseCrossDeviceAccountTransferAsSourceOptions>)
|
||||
| ({
|
||||
as: "target";
|
||||
} & Partial<UseCrossDeviceAccountTransferAsTargetOptions>)) {
|
||||
const initialMode = useRef(mode);
|
||||
|
||||
if (initialMode.current !== mode) {
|
||||
console.warn(
|
||||
"useAcceptAccountTransfer mode cannot be changed once mounted.",
|
||||
);
|
||||
}
|
||||
|
||||
if (initialMode.current === "source") {
|
||||
return useAcceptAccountTransferAsSource(
|
||||
window.location.origin,
|
||||
window.location.href,
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
return useAcceptAccountTransferAsTarget(
|
||||
window.location.origin,
|
||||
window.location.href,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
export { DemoAuthBasicUI } from "./DemoAuth.js";
|
||||
export { usePasskeyAuth, PasskeyAuthBasicUI } from "./PasskeyAuth.js";
|
||||
export { PassphraseAuthBasicUI } from "./PassphraseAuth.js";
|
||||
export {
|
||||
useCreateAccountTransfer,
|
||||
useAcceptAccountTransfer,
|
||||
} from "./CrossDeviceAccountTransfer.js";
|
||||
export {
|
||||
useIsAuthenticated,
|
||||
useDemoAuth,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
CryptoProvider,
|
||||
InviteSecret,
|
||||
base64URLtoBytes,
|
||||
bytesToBase64url,
|
||||
cojsonInternals,
|
||||
} from "cojson";
|
||||
import { Account } from "../../coValues/account.js";
|
||||
import { CoMap, Group } from "../../exports.js";
|
||||
import { ID, co } from "../../internal.js";
|
||||
import { AuthenticateAccountFunction } from "../../types.js";
|
||||
import { AuthSecretStorage } from "../AuthSecretStorage.js";
|
||||
import { CrossDeviceAccountTransferOptions } from "./types.js";
|
||||
import {
|
||||
createTemporaryAgent,
|
||||
defaultOptions,
|
||||
parseTransferUrl,
|
||||
shutdownTransferAccount,
|
||||
} from "./utils.js";
|
||||
|
||||
export class CrossDeviceAccountTransferCoMap extends CoMap {
|
||||
status = co.literal("pending", "incorrectCode", "authorized");
|
||||
secret = co.optional.string;
|
||||
acceptedBy = co.optional.ref(Account);
|
||||
confirmationCodeInput = co.optional.string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `CrossDeviceAccountTransfer` provides a `JazzAuth` object for secret URL authentication. Good for use in a QR code.
|
||||
*
|
||||
* ```ts
|
||||
* import { CrossDeviceAccountTransfer } from "jazz-tools";
|
||||
*
|
||||
* const auth = new CrossDeviceAccountTransfer(crypto, jazzContext.authenticate, new AuthSecretStorage(), window.location.origin, options);
|
||||
* ```
|
||||
*
|
||||
* @category Auth Providers
|
||||
*/
|
||||
export class CrossDeviceAccountTransfer {
|
||||
constructor(
|
||||
private crypto: CryptoProvider,
|
||||
private authenticate: AuthenticateAccountFunction,
|
||||
private authSecretStorage: AuthSecretStorage,
|
||||
private origin: string,
|
||||
options?: Partial<CrossDeviceAccountTransferOptions>,
|
||||
) {
|
||||
this.options = { ...defaultOptions, ...options };
|
||||
}
|
||||
|
||||
private options: CrossDeviceAccountTransferOptions;
|
||||
|
||||
/**
|
||||
* Creates a transfer URL for authentication.
|
||||
* @param transfer - The CrossDeviceAccountTransferCoMap to create the link for.
|
||||
* @returns A URL that can be displayed as a QR code to be scanned by the handler.
|
||||
*/
|
||||
public createLink(transfer: CrossDeviceAccountTransferCoMap) {
|
||||
let handlerUrl = this.origin + this.options.handlerPath;
|
||||
|
||||
let transferCore = transfer._raw.core;
|
||||
while (transferCore.verified.header.ruleset.type === "ownedByGroup") {
|
||||
transferCore = transferCore.getGroup().core;
|
||||
}
|
||||
|
||||
const group = cojsonInternals.expectGroup(transferCore.getCurrentContent());
|
||||
const inviteSecret = group.createInvite("writer");
|
||||
|
||||
const url = handlerUrl + `/${transfer.id}/${inviteSecret}`;
|
||||
|
||||
if (!url.includes("#")) {
|
||||
console.warn(
|
||||
"CrossDeviceAccountTransfer: URL does not include # - consider using a hash fragment to avoid leaking the transfer secret",
|
||||
);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a confirmation code using the configured function.
|
||||
* @returns The generated confirmation code.
|
||||
*/
|
||||
public async createConfirmationCode() {
|
||||
return await this.options.confirmationCodeFn(this.crypto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a transfer as a temporary agent.
|
||||
* @returns The created CrossDeviceAccountTransferCoMap.
|
||||
*/
|
||||
public async createTransfer() {
|
||||
const temporaryAgent = await createTemporaryAgent(this.crypto);
|
||||
const group = Group.create({ owner: temporaryAgent });
|
||||
|
||||
const transfer = CrossDeviceAccountTransferCoMap.create(
|
||||
{ status: "pending" },
|
||||
{ owner: group },
|
||||
);
|
||||
|
||||
return transfer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the secret seed from auth storage and reveal it to the transfer.
|
||||
* @param transfer - The CrossDeviceAccountTransferCoMap to reveal the secret to.
|
||||
*/
|
||||
public async revealSecretToTransfer(
|
||||
transfer: CrossDeviceAccountTransferCoMap,
|
||||
) {
|
||||
const credentials = await this.authSecretStorage.get();
|
||||
if (!credentials?.secretSeed) {
|
||||
throw new Error("No existing authentication found");
|
||||
}
|
||||
|
||||
transfer.secret = bytesToBase64url(credentials.secretSeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in via a transfer secret.
|
||||
* @param transfer - The CrossDeviceAccountTransferCoMap with the secret to log in with.
|
||||
*/
|
||||
public async logInViaTransfer(transfer: CrossDeviceAccountTransferCoMap) {
|
||||
const secret = transfer.secret;
|
||||
if (!secret) throw new Error("Transfer secret not set");
|
||||
transfer.status = "authorized";
|
||||
await transfer.waitForSync();
|
||||
shutdownTransferAccount(transfer);
|
||||
|
||||
const secretSeed = base64URLtoBytes(secret);
|
||||
const accountSecret = this.crypto.agentSecretFromSecretSeed(secretSeed);
|
||||
|
||||
const accountID = cojsonInternals.idforHeader(
|
||||
cojsonInternals.accountHeaderForInitialAgentSecret(
|
||||
accountSecret,
|
||||
this.crypto,
|
||||
),
|
||||
this.crypto,
|
||||
) as ID<Account>;
|
||||
|
||||
await this.authenticate({ accountID, accountSecret });
|
||||
|
||||
await this.authSecretStorage.set({
|
||||
accountID,
|
||||
secretSeed,
|
||||
accountSecret,
|
||||
provider: "crossDeviceAccountTransfer",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a transfer from a URL.
|
||||
* @param url - The URL to accept the transfer from.
|
||||
* @param handler - Specifies whether the URL is for consumer or provider.
|
||||
* @returns The accepted CrossDeviceAccountTransferCoMap.
|
||||
*/
|
||||
public async acceptTransferUrl(url: string) {
|
||||
const { transferId, inviteSecret } = parseTransferUrl(
|
||||
this.options.handlerPath,
|
||||
url,
|
||||
);
|
||||
|
||||
const account = await createTemporaryAgent(this.crypto);
|
||||
|
||||
const transfer = await account.acceptInvite(
|
||||
transferId,
|
||||
inviteSecret,
|
||||
CrossDeviceAccountTransferCoMap,
|
||||
);
|
||||
if (!transfer) throw new Error("Failed to accept invite");
|
||||
|
||||
transfer.acceptedBy = account;
|
||||
|
||||
return transfer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a valid transfer URL.
|
||||
* @param url - The URL to check.
|
||||
* @param handler - Specifies whether the URL is for source or target.
|
||||
* @returns True if the URL is a valid transfer URL, false otherwise.
|
||||
*/
|
||||
public checkValidUrl(url: string, handler: "source" | "target") {
|
||||
try {
|
||||
parseTransferUrl(this.options.handlerPath, url);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { waitForCoValueCondition } from "../../internal.js";
|
||||
import { CrossDeviceAccountTransfer } from "./CrossDeviceAccountTransfer.js";
|
||||
import { CrossDeviceAccountTransferAsSourceOptions } from "./types.js";
|
||||
import { shutdownTransferAccount } from "./utils.js";
|
||||
|
||||
export type CrossDeviceAccountTransferCreateAsSourceStatus =
|
||||
| "idle"
|
||||
| "waitingForHandler"
|
||||
| "confirmationCodeGenerated"
|
||||
| "confirmationCodeCorrect"
|
||||
| "confirmationCodeIncorrect"
|
||||
| "authorized"
|
||||
| "error"
|
||||
| "cancelled";
|
||||
|
||||
export class CrossDeviceAccountTransferCreateAsSource {
|
||||
constructor(
|
||||
private crossDeviceAccountTransfer: CrossDeviceAccountTransfer,
|
||||
options?: CrossDeviceAccountTransferAsSourceOptions,
|
||||
) {
|
||||
this.options = { ...defaultOptions, ...options };
|
||||
}
|
||||
|
||||
private options: CrossDeviceAccountTransferAsSourceOptions;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
public authState: {
|
||||
status: CrossDeviceAccountTransferCreateAsSourceStatus;
|
||||
confirmationCode: string | undefined;
|
||||
} = {
|
||||
status: "idle",
|
||||
confirmationCode: undefined,
|
||||
};
|
||||
|
||||
private set status(status: CrossDeviceAccountTransferCreateAsSourceStatus) {
|
||||
this.authState = { ...this.authState, status };
|
||||
}
|
||||
private set confirmationCode(confirmationCode: string | undefined) {
|
||||
this.authState = { ...this.authState, confirmationCode };
|
||||
}
|
||||
|
||||
public createLink = async () => {
|
||||
this.abortController = new AbortController();
|
||||
const { signal } = this.abortController;
|
||||
|
||||
let transfer = await this.crossDeviceAccountTransfer.createTransfer();
|
||||
|
||||
const url = this.crossDeviceAccountTransfer.createLink(transfer);
|
||||
|
||||
const handleFlow = async () => {
|
||||
try {
|
||||
// Wait for target device to accept the transfer
|
||||
this.status = "waitingForHandler";
|
||||
this.notify();
|
||||
|
||||
transfer = await waitForCoValueCondition(
|
||||
transfer,
|
||||
{ abortSignal: signal },
|
||||
(t) => Boolean(t.acceptedBy),
|
||||
this.options.expireInMs,
|
||||
);
|
||||
|
||||
if (!transfer.acceptedBy) throw new Error("Transfer not accepted");
|
||||
|
||||
// Wait for confirmation code
|
||||
const code =
|
||||
await this.crossDeviceAccountTransfer.createConfirmationCode();
|
||||
this.confirmationCode = code;
|
||||
this.status = "confirmationCodeGenerated";
|
||||
this.notify();
|
||||
|
||||
transfer = await waitForCoValueCondition(
|
||||
transfer,
|
||||
{ abortSignal: signal },
|
||||
(t) => Boolean(t.confirmationCodeInput),
|
||||
this.options.expireInMs,
|
||||
);
|
||||
|
||||
// Check if the confirmation code is correct
|
||||
if (transfer.confirmationCodeInput !== code) {
|
||||
transfer.status = "incorrectCode";
|
||||
await transfer.waitForSync();
|
||||
this.status = "confirmationCodeIncorrect";
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
this.status = "confirmationCodeCorrect";
|
||||
|
||||
// Reveal the secret to the transfer
|
||||
await this.crossDeviceAccountTransfer.revealSecretToTransfer(transfer);
|
||||
|
||||
// Wait for the transfer to be authorized and update the status
|
||||
await waitForCoValueCondition(
|
||||
transfer,
|
||||
{ abortSignal: signal },
|
||||
(t) => t.status === "authorized",
|
||||
);
|
||||
this.status = "authorized";
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Aborted")) {
|
||||
this.status = "cancelled";
|
||||
} else {
|
||||
console.error("Cross-Device Account Transfer error", error);
|
||||
this.status = "error";
|
||||
}
|
||||
this.notify();
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
shutdownTransferAccount(transfer);
|
||||
}
|
||||
};
|
||||
|
||||
handleFlow();
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
public cancelFlow() {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
listeners = new Set<() => void>();
|
||||
subscribe = (callback: () => void): (() => void) => {
|
||||
this.listeners.add(callback);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
};
|
||||
notify() {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultOptions: CrossDeviceAccountTransferAsSourceOptions = {
|
||||
expireInMs: 15 * 60 * 1000,
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { waitForCoValueCondition } from "../../internal.js";
|
||||
import { CrossDeviceAccountTransfer } from "./CrossDeviceAccountTransfer.js";
|
||||
import { CrossDeviceAccountTransferAsTargetOptions } from "./types.js";
|
||||
import { shutdownTransferAccount } from "./utils.js";
|
||||
|
||||
export type CrossDeviceAccountTransferCreateAsTargetStatus =
|
||||
| "idle"
|
||||
| "waitingForHandler"
|
||||
| "confirmationCodeRequired"
|
||||
| "confirmationCodePending"
|
||||
| "confirmationCodeIncorrect"
|
||||
| "authorized"
|
||||
| "error"
|
||||
| "cancelled";
|
||||
|
||||
export class CrossDeviceAccountTransferCreateAsTarget {
|
||||
constructor(
|
||||
private crossDeviceAccountTransfer: CrossDeviceAccountTransfer,
|
||||
options?: CrossDeviceAccountTransferAsTargetOptions,
|
||||
) {
|
||||
this.options = { ...defaultOptions, ...options };
|
||||
}
|
||||
|
||||
private options: CrossDeviceAccountTransferAsTargetOptions;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
public authState: {
|
||||
status: CrossDeviceAccountTransferCreateAsTargetStatus;
|
||||
sendConfirmationCode: undefined | ((code: string) => void);
|
||||
} = {
|
||||
status: "idle",
|
||||
sendConfirmationCode: undefined,
|
||||
};
|
||||
|
||||
private set status(status: CrossDeviceAccountTransferCreateAsTargetStatus) {
|
||||
this.authState = { ...this.authState, status };
|
||||
}
|
||||
private set sendConfirmationCode(sendConfirmationCode:
|
||||
| undefined
|
||||
| ((code: string) => void)) {
|
||||
this.authState = { ...this.authState, sendConfirmationCode };
|
||||
}
|
||||
|
||||
public async createLink() {
|
||||
this.abortController = new AbortController();
|
||||
const { signal } = this.abortController;
|
||||
|
||||
let transfer = await this.crossDeviceAccountTransfer.createTransfer();
|
||||
|
||||
const url = this.crossDeviceAccountTransfer.createLink(transfer);
|
||||
|
||||
const handleFlow = async () => {
|
||||
try {
|
||||
// Wait for the source device to accept the transfer
|
||||
this.status = "waitingForHandler";
|
||||
this.notify();
|
||||
|
||||
transfer = await waitForCoValueCondition(
|
||||
transfer,
|
||||
{ abortSignal: signal },
|
||||
(t) => Boolean(t.acceptedBy),
|
||||
this.options.handlerTimeout,
|
||||
);
|
||||
|
||||
this.status = "confirmationCodeRequired";
|
||||
this.notify();
|
||||
|
||||
const code = await new Promise<string>((resolve, reject) => {
|
||||
this.sendConfirmationCode = (code: string) => resolve(code);
|
||||
signal.addEventListener("abort", () => reject(new Error("Aborted")));
|
||||
});
|
||||
|
||||
transfer.confirmationCodeInput = code;
|
||||
this.status = "confirmationCodePending";
|
||||
this.notify();
|
||||
|
||||
// Wait for source device to reject or confirm and reveal the secret
|
||||
transfer = await waitForCoValueCondition(
|
||||
transfer,
|
||||
{ resolve: {}, abortSignal: signal },
|
||||
(t) => t.status === "incorrectCode" || Boolean(t.secret),
|
||||
this.options.handlerTimeout,
|
||||
);
|
||||
|
||||
if (transfer.status === "incorrectCode") {
|
||||
this.status = "confirmationCodeIncorrect";
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Log in using the transfer secret
|
||||
await this.crossDeviceAccountTransfer.logInViaTransfer(transfer);
|
||||
this.status = "authorized";
|
||||
this.notify();
|
||||
this.options.onLoggedIn?.();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Aborted")) {
|
||||
this.status = "cancelled";
|
||||
} else {
|
||||
console.error("Cross-Device Account Transfer error", error);
|
||||
this.status = "error";
|
||||
}
|
||||
this.notify();
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
shutdownTransferAccount(transfer);
|
||||
}
|
||||
};
|
||||
|
||||
handleFlow();
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
public cancelFlow() {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
listeners = new Set<() => void>();
|
||||
subscribe = (callback: () => void): (() => void) => {
|
||||
this.listeners.add(callback);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
};
|
||||
notify() {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultOptions: CrossDeviceAccountTransferAsTargetOptions = {
|
||||
handlerTimeout: 15 * 60 * 1000,
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { waitForCoValueCondition } from "../../internal.js";
|
||||
import {
|
||||
CrossDeviceAccountTransfer,
|
||||
CrossDeviceAccountTransferCoMap,
|
||||
} from "./CrossDeviceAccountTransfer.js";
|
||||
import { CrossDeviceAccountTransferAsSourceOptions } from "./types.js";
|
||||
import { shutdownTransferAccount } from "./utils.js";
|
||||
|
||||
export type CrossDeviceAccountTransferHandleAsSourceStatus =
|
||||
| "idle"
|
||||
| "confirmationCodeGenerated"
|
||||
| "confirmationCodeIncorrect"
|
||||
| "authorized"
|
||||
| "error"
|
||||
| "cancelled";
|
||||
|
||||
export class CrossDeviceAccountTransferHandleAsSource {
|
||||
constructor(
|
||||
private crossDeviceAccountTransfer: CrossDeviceAccountTransfer,
|
||||
options?: CrossDeviceAccountTransferAsSourceOptions,
|
||||
) {
|
||||
this.options = { ...defaultOptions, ...options };
|
||||
}
|
||||
|
||||
private options: CrossDeviceAccountTransferAsSourceOptions;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
public authState: {
|
||||
status: CrossDeviceAccountTransferHandleAsSourceStatus;
|
||||
confirmationCode: string | undefined;
|
||||
} = {
|
||||
status: "idle",
|
||||
confirmationCode: undefined,
|
||||
};
|
||||
|
||||
private set status(status: CrossDeviceAccountTransferHandleAsSourceStatus) {
|
||||
this.authState = { ...this.authState, status };
|
||||
}
|
||||
private set confirmationCode(confirmationCode: string | undefined) {
|
||||
this.authState = { ...this.authState, confirmationCode };
|
||||
}
|
||||
|
||||
public async handleFlow(url: string) {
|
||||
this.abortController = new AbortController();
|
||||
const { signal } = this.abortController;
|
||||
|
||||
let transfer: CrossDeviceAccountTransferCoMap | undefined;
|
||||
|
||||
try {
|
||||
transfer = await this.crossDeviceAccountTransfer.acceptTransferUrl(url);
|
||||
|
||||
// Generate and set confirmation code
|
||||
const code =
|
||||
await this.crossDeviceAccountTransfer.createConfirmationCode();
|
||||
this.confirmationCode = code;
|
||||
this.status = "confirmationCodeGenerated";
|
||||
this.notify();
|
||||
|
||||
// Wait for confirmation code input
|
||||
transfer = await waitForCoValueCondition(
|
||||
transfer,
|
||||
{ abortSignal: signal },
|
||||
(t) => Boolean(t.confirmationCodeInput),
|
||||
this.options.expireInMs,
|
||||
);
|
||||
|
||||
// Check if the confirmation code is correct
|
||||
if (transfer.confirmationCodeInput !== code) {
|
||||
transfer.status = "incorrectCode";
|
||||
await transfer.waitForSync();
|
||||
this.status = "confirmationCodeIncorrect";
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reveal the secret to the transfer
|
||||
await this.crossDeviceAccountTransfer.revealSecretToTransfer(transfer);
|
||||
|
||||
// Wait for the transfer to be authorized and update the status
|
||||
await waitForCoValueCondition(
|
||||
transfer,
|
||||
{ abortSignal: signal },
|
||||
(t) => t.status === "authorized",
|
||||
);
|
||||
this.status = "authorized";
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Aborted")) {
|
||||
this.status = "cancelled";
|
||||
} else {
|
||||
console.error("Cross-Device Account Transfer error", error);
|
||||
this.status = "error";
|
||||
}
|
||||
this.notify();
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
shutdownTransferAccount(transfer);
|
||||
}
|
||||
}
|
||||
|
||||
public cancelFlow() {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
public checkValidUrl(url: string) {
|
||||
return this.crossDeviceAccountTransfer.checkValidUrl(url, "source");
|
||||
}
|
||||
|
||||
listeners = new Set<() => void>();
|
||||
subscribe = (callback: () => void): (() => void) => {
|
||||
this.listeners.add(callback);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
};
|
||||
notify() {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultOptions: CrossDeviceAccountTransferAsSourceOptions = {
|
||||
expireInMs: 15 * 60 * 1000,
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { waitForCoValueCondition } from "../../internal.js";
|
||||
import {
|
||||
CrossDeviceAccountTransfer,
|
||||
CrossDeviceAccountTransferCoMap,
|
||||
} from "./CrossDeviceAccountTransfer.js";
|
||||
import { CrossDeviceAccountTransferAsTargetOptions } from "./types.js";
|
||||
import { shutdownTransferAccount } from "./utils.js";
|
||||
|
||||
export type CrossDeviceAccountTransferHandleAsTargetStatus =
|
||||
| "idle"
|
||||
| "confirmationCodeRequired"
|
||||
| "confirmationCodePending"
|
||||
| "confirmationCodeIncorrect"
|
||||
| "authorized"
|
||||
| "error"
|
||||
| "cancelled";
|
||||
|
||||
export class CrossDeviceAccountTransferHandleAsTarget {
|
||||
constructor(
|
||||
private crossDeviceAccountTransfer: CrossDeviceAccountTransfer,
|
||||
options?: CrossDeviceAccountTransferAsTargetOptions,
|
||||
) {
|
||||
this.options = { ...defaultOptions, ...options };
|
||||
}
|
||||
|
||||
private options: CrossDeviceAccountTransferAsTargetOptions;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
public authState: {
|
||||
status: CrossDeviceAccountTransferHandleAsTargetStatus;
|
||||
sendConfirmationCode: null | ((code: string) => void);
|
||||
} = {
|
||||
status: "idle",
|
||||
sendConfirmationCode: null,
|
||||
};
|
||||
|
||||
private set status(status: CrossDeviceAccountTransferHandleAsTargetStatus) {
|
||||
this.authState = { ...this.authState, status };
|
||||
}
|
||||
private set sendConfirmationCode(sendConfirmationCode:
|
||||
| null
|
||||
| ((code: string) => void)) {
|
||||
this.authState = { ...this.authState, sendConfirmationCode };
|
||||
}
|
||||
|
||||
public async handleFlow(url: string) {
|
||||
this.abortController = new AbortController();
|
||||
const { signal } = this.abortController;
|
||||
|
||||
let transfer: CrossDeviceAccountTransferCoMap | undefined;
|
||||
|
||||
try {
|
||||
transfer = await this.crossDeviceAccountTransfer.acceptTransferUrl(url);
|
||||
|
||||
this.status = "confirmationCodeRequired";
|
||||
this.notify();
|
||||
|
||||
const code = await new Promise<string>((resolve, reject) => {
|
||||
this.sendConfirmationCode = (code: string) => resolve(code);
|
||||
signal.addEventListener("abort", () => reject(new Error("Aborted")));
|
||||
});
|
||||
|
||||
transfer.confirmationCodeInput = code;
|
||||
this.status = "confirmationCodePending";
|
||||
this.notify();
|
||||
|
||||
// Wait for source device to reject or confirm and reveal the secret
|
||||
transfer = await waitForCoValueCondition(
|
||||
transfer,
|
||||
{ resolve: {}, abortSignal: signal },
|
||||
(t) => t.status === "incorrectCode" || Boolean(t.secret),
|
||||
this.options.handlerTimeout,
|
||||
);
|
||||
|
||||
if (transfer.status === "incorrectCode") {
|
||||
this.status = "confirmationCodeIncorrect";
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.crossDeviceAccountTransfer.logInViaTransfer(transfer);
|
||||
this.status = "authorized";
|
||||
this.notify();
|
||||
this.options.onLoggedIn?.();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Aborted")) {
|
||||
this.status = "cancelled";
|
||||
} else {
|
||||
console.error("Cross-Device Account Transfer error", error);
|
||||
this.status = "error";
|
||||
}
|
||||
this.notify();
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
shutdownTransferAccount(transfer);
|
||||
}
|
||||
}
|
||||
|
||||
public cancelFlow() {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
public checkValidUrl(url: string) {
|
||||
return this.crossDeviceAccountTransfer.checkValidUrl(url, "target");
|
||||
}
|
||||
|
||||
listeners = new Set<() => void>();
|
||||
subscribe = (callback: () => void): (() => void) => {
|
||||
this.listeners.add(callback);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
};
|
||||
|
||||
notify() {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultOptions: CrossDeviceAccountTransferAsTargetOptions = {
|
||||
handlerTimeout: 30 * 1000,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./CrossDeviceAccountTransfer.js";
|
||||
export * from "./CrossDeviceAccountTransferCreateAsSource.js";
|
||||
export * from "./CrossDeviceAccountTransferCreateAsTarget.js";
|
||||
export * from "./CrossDeviceAccountTransferHandleAsTarget.js";
|
||||
export * from "./CrossDeviceAccountTransferHandleAsSource.js";
|
||||
export * from "./types.js";
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CryptoProvider } from "cojson";
|
||||
|
||||
/**
|
||||
* Options for the CrossDeviceAccountTransfer class.
|
||||
*/
|
||||
export interface CrossDeviceAccountTransferOptions {
|
||||
/**
|
||||
* Function to generate a confirmation code.
|
||||
* @param crypto - The crypto provider to use for random number generation.
|
||||
* @returns The generated confirmation code.
|
||||
*/
|
||||
confirmationCodeFn: (crypto: CryptoProvider) => string | Promise<string>;
|
||||
handlerPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for CrossDeviceAccountTransfer consumer classes.
|
||||
*/
|
||||
export interface CrossDeviceAccountTransferAsTargetOptions {
|
||||
/**
|
||||
* The timeout for the consumer handler.
|
||||
*/
|
||||
handlerTimeout?: number;
|
||||
/**
|
||||
* The function to call when the consumer is logged in.
|
||||
*/
|
||||
onLoggedIn?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for CrossDeviceAccountTransfer provider classes.
|
||||
*/
|
||||
export interface CrossDeviceAccountTransferAsSourceOptions {
|
||||
/**
|
||||
* The expiration time for the provider.
|
||||
*/
|
||||
expireInMs?: number;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
type CryptoProvider,
|
||||
type InviteSecret,
|
||||
LocalNode,
|
||||
cojsonInternals,
|
||||
} from "cojson";
|
||||
import { Account } from "../../coValues/account.js";
|
||||
import type { ID } from "../../internal.js";
|
||||
import type { CrossDeviceAccountTransferCoMap } from "./CrossDeviceAccountTransfer.js";
|
||||
import type { CrossDeviceAccountTransferOptions } from "./types.js";
|
||||
|
||||
/**
|
||||
* Create a temporary agent to keep the transfer secret isolated from persistent accounts.
|
||||
* @param crypto - The crypto provider to use for agent creation.
|
||||
* @returns The created Account.
|
||||
*/
|
||||
export async function createTemporaryAgent(crypto: CryptoProvider) {
|
||||
const { node } = await LocalNode.withNewlyCreatedAccount({
|
||||
creationProps: { name: "Sandbox account" },
|
||||
peersToLoadFrom: [],
|
||||
crypto,
|
||||
});
|
||||
const account = Account.fromNode(node);
|
||||
|
||||
const [localPeer, authTransferPeer] = cojsonInternals.connectedPeers(
|
||||
"local",
|
||||
// Use an unique identifier to avoid conflicts with other cross-device account transfer instances
|
||||
"crossDeviceAccountTransfer/" + account.id,
|
||||
{ peer1role: "server", peer2role: "client" },
|
||||
);
|
||||
|
||||
Account.getMe()._raw.core.node.syncManager.addPeer(authTransferPeer);
|
||||
account._raw.core.node.syncManager.addPeer(localPeer);
|
||||
|
||||
await account.waitForAllCoValuesSync();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a transfer URL.
|
||||
* @param handlerPath - The path of the handler.
|
||||
* @param url - The URL to parse.
|
||||
* @returns The transfer ID and invite secret.
|
||||
*/
|
||||
export function parseTransferUrl(handlerPath: string, url: string) {
|
||||
const re = new RegExp(`${handlerPath}/(co_z[^/]+)/(inviteSecret_z[^/]+)$`);
|
||||
|
||||
const match = url.match(re);
|
||||
if (!match) throw new Error("Invalid URL");
|
||||
|
||||
const transferId = match[1] as
|
||||
| ID<CrossDeviceAccountTransferCoMap>
|
||||
| undefined;
|
||||
const inviteSecret = match[2] as InviteSecret | undefined;
|
||||
|
||||
if (!transferId || !inviteSecret) throw new Error("Invalid URL");
|
||||
|
||||
return { transferId, inviteSecret };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default function to generate a 6-digit confirmation code.
|
||||
* @param crypto - The crypto provider to use for random number generation.
|
||||
* @returns The generated confirmation code.
|
||||
*/
|
||||
async function defaultConfirmationCodeFn(crypto: CryptoProvider) {
|
||||
let code = "";
|
||||
while (code.length < 6) {
|
||||
// value is 0-15
|
||||
const value = crypto.randomBytes(1)[0]! & 0x0f;
|
||||
// discard values >=10 for uniform distribution 0-9
|
||||
if (value >= 10) continue;
|
||||
code += value.toString();
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export function shutdownTransferAccount(
|
||||
transfer: CrossDeviceAccountTransferCoMap | undefined,
|
||||
) {
|
||||
if (!transfer || transfer._loadedAs._type !== "Account") return;
|
||||
transfer._loadedAs._raw.core.node.gracefulShutdown();
|
||||
}
|
||||
|
||||
export const defaultOptions: CrossDeviceAccountTransferOptions = {
|
||||
confirmationCodeFn: defaultConfirmationCodeFn,
|
||||
handlerPath: "/accept-account-transfer",
|
||||
};
|
||||
@@ -381,6 +381,65 @@ export function subscribeToExistingCoValue<
|
||||
);
|
||||
}
|
||||
|
||||
export function waitForCoValueCondition<
|
||||
V extends CoValue,
|
||||
const R extends RefsToResolve<V>,
|
||||
>(
|
||||
existing: V,
|
||||
{
|
||||
abortSignal,
|
||||
...options
|
||||
}: {
|
||||
abortSignal?: AbortSignal;
|
||||
resolve?: RefsToResolveStrict<V, R>;
|
||||
onUnavailable?: () => void;
|
||||
onUnauthorized?: () => void;
|
||||
},
|
||||
conditionFn: (value: V) => boolean,
|
||||
timeoutMs = 15000,
|
||||
): Promise<V> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
let unsubscribe = () => {};
|
||||
|
||||
const cleanUp = () => {
|
||||
done = true;
|
||||
unsubscribe();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
const abort = () => {
|
||||
cleanUp();
|
||||
reject(new Error("Aborted waiting for CoValue condition"));
|
||||
};
|
||||
|
||||
abortSignal?.addEventListener("abort", abort);
|
||||
|
||||
subscribeToCoValue(
|
||||
existing.constructor as CoValueClass<V>,
|
||||
existing.id,
|
||||
{
|
||||
loadAs: existing._loadedAs,
|
||||
...options,
|
||||
},
|
||||
(value, unsubscribeParam) => {
|
||||
unsubscribe = unsubscribeParam;
|
||||
if (done) return;
|
||||
|
||||
if (conditionFn(value)) {
|
||||
cleanUp();
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanUp();
|
||||
reject(new Error("Timeout waiting for CoValue condition"));
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
export function isAccountInstance(instance: unknown): instance is Account {
|
||||
if (typeof instance !== "object" || instance === null) {
|
||||
return false;
|
||||
|
||||
@@ -64,6 +64,17 @@ export { KvStoreContext, type KvStore } from "./auth/KvStoreContext.js";
|
||||
export { InMemoryKVStore } from "./auth/InMemoryKVStore.js";
|
||||
export { DemoAuth } from "./auth/DemoAuth.js";
|
||||
export { PassphraseAuth } from "./auth/PassphraseAuth.js";
|
||||
export {
|
||||
CrossDeviceAccountTransfer,
|
||||
CrossDeviceAccountTransferCoMap,
|
||||
type CrossDeviceAccountTransferOptions,
|
||||
CrossDeviceAccountTransferCreateAsSource,
|
||||
CrossDeviceAccountTransferCreateAsTarget,
|
||||
CrossDeviceAccountTransferHandleAsTarget,
|
||||
CrossDeviceAccountTransferHandleAsSource,
|
||||
type CrossDeviceAccountTransferAsTargetOptions,
|
||||
type CrossDeviceAccountTransferAsSourceOptions,
|
||||
} from "./auth/CrossDeviceAccountTransfer/index.js";
|
||||
|
||||
export {
|
||||
createInviteLink,
|
||||
|
||||
340
packages/jazz-tools/src/tests/CrossDeviceAccountTransfer.test.ts
Normal file
340
packages/jazz-tools/src/tests/CrossDeviceAccountTransfer.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { AgentSecret, bytesToBase64url } from "cojson";
|
||||
import { PureJSCrypto } from "cojson/crypto/PureJSCrypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CrossDeviceAccountTransfer,
|
||||
CrossDeviceAccountTransferCreateAsSource,
|
||||
CrossDeviceAccountTransferCreateAsTarget,
|
||||
CrossDeviceAccountTransferHandleAsSource,
|
||||
CrossDeviceAccountTransferHandleAsTarget,
|
||||
} from "../auth/CrossDeviceAccountTransfer";
|
||||
import {
|
||||
Account,
|
||||
AuthSecretStorage,
|
||||
ID,
|
||||
InMemoryKVStore,
|
||||
KvStoreContext,
|
||||
} from "../exports";
|
||||
import { createJazzTestAccount } from "../testing";
|
||||
import { waitFor } from "./utils";
|
||||
|
||||
// Initialize KV store for tests
|
||||
KvStoreContext.getInstance().initialize(new InMemoryKVStore());
|
||||
|
||||
describe("CrossDeviceAccountTransfer", () => {
|
||||
let crypto: PureJSCrypto;
|
||||
let mockAuthenticate: any;
|
||||
let authSecretStorage: AuthSecretStorage;
|
||||
let crossDeviceAccountTransfer: CrossDeviceAccountTransfer;
|
||||
let account: Account;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Reset storage
|
||||
KvStoreContext.getInstance().getStorage().clearAll();
|
||||
|
||||
// Set up crypto and mocks
|
||||
crypto = await PureJSCrypto.create();
|
||||
mockAuthenticate = vi.fn();
|
||||
authSecretStorage = new AuthSecretStorage();
|
||||
const secretSeed = crypto.newRandomSecretSeed();
|
||||
await authSecretStorage.set({
|
||||
accountID: "test-account" as ID<Account>,
|
||||
accountSecret: "test-secret" as AgentSecret,
|
||||
secretSeed,
|
||||
provider: "anonymous",
|
||||
});
|
||||
|
||||
account = await createJazzTestAccount({
|
||||
isCurrentActiveAccount: true,
|
||||
});
|
||||
|
||||
// Create CrossDeviceAccountTransfer instance
|
||||
crossDeviceAccountTransfer = new CrossDeviceAccountTransfer(
|
||||
crypto,
|
||||
mockAuthenticate,
|
||||
authSecretStorage,
|
||||
"http://localhost:3000",
|
||||
);
|
||||
});
|
||||
|
||||
describe("createTransferAsProvider", () => {
|
||||
it("creates a transfer", async () => {
|
||||
const transfer = await crossDeviceAccountTransfer.createTransfer();
|
||||
|
||||
expect(transfer.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTransferAsConsumer", () => {
|
||||
it("creates a transfer", async () => {
|
||||
const transfer = await crossDeviceAccountTransfer.createTransfer();
|
||||
|
||||
// The transfer should NOT be loaded as the logged-in account
|
||||
expect((transfer._loadedAs as Account).id).not.toBe(account.id);
|
||||
|
||||
expect(transfer.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConfirmationCode", () => {
|
||||
it("creates a code", async () => {
|
||||
const mockRandomBytes = vi.fn();
|
||||
mockRandomBytes.mockReturnValue(new Uint8Array([1]));
|
||||
crypto.randomBytes = mockRandomBytes;
|
||||
|
||||
const code = await crossDeviceAccountTransfer.createConfirmationCode();
|
||||
|
||||
expect(code).toBe("111111");
|
||||
});
|
||||
|
||||
it("creates a code using custom code function", async () => {
|
||||
crossDeviceAccountTransfer = new CrossDeviceAccountTransfer(
|
||||
crypto,
|
||||
mockAuthenticate,
|
||||
authSecretStorage,
|
||||
"http://localhost:3000",
|
||||
{ confirmationCodeFn: () => Promise.resolve("123456") },
|
||||
);
|
||||
|
||||
const code = await crossDeviceAccountTransfer.createConfirmationCode();
|
||||
expect(code).toBe("123456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logInViaTransfer", () => {
|
||||
it("logs in via transfer", async () => {
|
||||
const transfer = await crossDeviceAccountTransfer.createTransfer();
|
||||
|
||||
transfer.secret = bytesToBase64url(
|
||||
new Uint8Array([
|
||||
173, 58, 235, 40, 67, 188, 236, 11, 107, 237, 97, 23, 182, 49, 188,
|
||||
63, 237, 52, 27, 84, 142, 66, 244, 149, 243, 114, 203, 164, 115, 239,
|
||||
175, 194,
|
||||
]),
|
||||
);
|
||||
|
||||
await crossDeviceAccountTransfer.logInViaTransfer(transfer);
|
||||
|
||||
expect(mockAuthenticate).toHaveBeenCalledWith({
|
||||
accountID: expect.stringMatching(/^co_[^/]+$/),
|
||||
accountSecret: expect.stringMatching(
|
||||
/^sealerSecret_[^/]+\/signerSecret_[^/]+$/,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CrossDeviceAccountTransferCreateAsConsumer", () => {
|
||||
it("should initialize", () => {
|
||||
const createAsConsumer = new CrossDeviceAccountTransferCreateAsTarget(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
|
||||
expect(createAsConsumer.authState.status).toEqual("idle");
|
||||
expect(createAsConsumer.authState.sendConfirmationCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should cancel flow", async () => {
|
||||
const createAsConsumer = new CrossDeviceAccountTransferCreateAsTarget(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
await createAsConsumer.createLink();
|
||||
|
||||
createAsConsumer.cancelFlow();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createAsConsumer.authState.status).toEqual("cancelled");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CrossDeviceAccountTransferHandleAsProvider", () => {
|
||||
it("should initialize", () => {
|
||||
const handleAsProvider = new CrossDeviceAccountTransferHandleAsSource(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
|
||||
expect(handleAsProvider.authState.status).toEqual("idle");
|
||||
expect(handleAsProvider.authState.confirmationCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should cancel flow", async () => {
|
||||
// Create the link as consumer
|
||||
const createAsConsumer = new CrossDeviceAccountTransferCreateAsTarget(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
const link = await createAsConsumer.createLink();
|
||||
|
||||
// Handle the flow as provider
|
||||
const handleAsProvider = new CrossDeviceAccountTransferHandleAsSource(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
handleAsProvider.handleFlow(link);
|
||||
|
||||
setTimeout(() => {
|
||||
handleAsProvider.cancelFlow();
|
||||
}, 50);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleAsProvider.authState.status).toEqual("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle the flow", async () => {
|
||||
// Create the link as consumer
|
||||
const createAsConsumer = new CrossDeviceAccountTransferCreateAsTarget(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
const link = await createAsConsumer.createLink();
|
||||
expect(link).toMatch(
|
||||
/^http:\/\/localhost:3000\/accept-account-transfer\/co_[^/]+\/inviteSecret_[^/]+$/,
|
||||
);
|
||||
expect(createAsConsumer.authState.status).toEqual("waitingForHandler");
|
||||
|
||||
// Handle the flow as provider
|
||||
const handleAsProvider = new CrossDeviceAccountTransferHandleAsSource(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
handleAsProvider.handleFlow(link);
|
||||
await waitFor(() => {
|
||||
expect(createAsConsumer.authState.status).toEqual(
|
||||
"confirmationCodeRequired",
|
||||
);
|
||||
expect(createAsConsumer.authState.sendConfirmationCode).toBeDefined();
|
||||
|
||||
expect(handleAsProvider.authState.status).toEqual(
|
||||
"confirmationCodeGenerated",
|
||||
);
|
||||
expect(handleAsProvider.authState.confirmationCode).toBeDefined();
|
||||
});
|
||||
|
||||
// Enter the confirmation code
|
||||
createAsConsumer.authState.sendConfirmationCode?.(
|
||||
handleAsProvider.authState.confirmationCode!,
|
||||
);
|
||||
// Check authorized
|
||||
await waitFor(() => {
|
||||
expect(createAsConsumer.authState.status).toEqual("authorized");
|
||||
});
|
||||
expect(handleAsProvider.authState.status).toEqual("authorized");
|
||||
|
||||
expect(mockAuthenticate).toHaveBeenCalledWith({
|
||||
accountID: expect.stringMatching(/^co_[^/]+$/),
|
||||
accountSecret: expect.stringMatching(
|
||||
/^sealerSecret_[^/]+\/signerSecret_[^/]+$/,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CrossDeviceAccountTransferCreateAsProvider", () => {
|
||||
it("should initialize", () => {
|
||||
const createAsProvider = new CrossDeviceAccountTransferCreateAsSource(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
|
||||
expect(createAsProvider.authState.status).toEqual("idle");
|
||||
expect(createAsProvider.authState.confirmationCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should cancel flow", async () => {
|
||||
const createAsProvider = new CrossDeviceAccountTransferCreateAsSource(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
await createAsProvider.createLink();
|
||||
|
||||
setTimeout(() => {
|
||||
createAsProvider.cancelFlow();
|
||||
}, 50);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createAsProvider.authState.status).toEqual("cancelled");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CrossDeviceAccountTransferHandleAsConsumer", () => {
|
||||
it("should initialize", () => {
|
||||
const handleAsConsumer = new CrossDeviceAccountTransferHandleAsTarget(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
|
||||
expect(handleAsConsumer.authState.status).toEqual("idle");
|
||||
expect(handleAsConsumer.authState.sendConfirmationCode).toBeNull();
|
||||
});
|
||||
|
||||
it("should cancel flow", async () => {
|
||||
// Create the link as provider
|
||||
const createAsProvider = new CrossDeviceAccountTransferCreateAsSource(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
const link = await createAsProvider.createLink();
|
||||
|
||||
// Handle the flow as consumer
|
||||
const handleAsConsumer = new CrossDeviceAccountTransferHandleAsTarget(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
handleAsConsumer.handleFlow(link);
|
||||
|
||||
// Cancel the flow
|
||||
setTimeout(() => {
|
||||
handleAsConsumer.cancelFlow();
|
||||
}, 50);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleAsConsumer.authState.status).toEqual("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle the flow", async () => {
|
||||
// Create the link as provider
|
||||
const createAsProvider = new CrossDeviceAccountTransferCreateAsSource(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
const link = await createAsProvider.createLink();
|
||||
expect(link).toMatch(
|
||||
/^http:\/\/localhost:3000\/accept-account-transfer\/co_[^/]+\/inviteSecret_[^/]+$/,
|
||||
);
|
||||
expect(createAsProvider.authState.status).toEqual("waitingForHandler");
|
||||
|
||||
// Handle the flow as consumer
|
||||
const handleAsConsumer = new CrossDeviceAccountTransferHandleAsTarget(
|
||||
crossDeviceAccountTransfer,
|
||||
);
|
||||
handleAsConsumer.handleFlow(link);
|
||||
await waitFor(() => {
|
||||
expect(createAsProvider.authState.status).toEqual(
|
||||
"confirmationCodeGenerated",
|
||||
);
|
||||
expect(createAsProvider.authState.confirmationCode).toBeDefined();
|
||||
|
||||
expect(handleAsConsumer.authState.status).toEqual(
|
||||
"confirmationCodeRequired",
|
||||
);
|
||||
expect(handleAsConsumer.authState.sendConfirmationCode).toBeDefined();
|
||||
});
|
||||
|
||||
// Enter the confirmation code
|
||||
handleAsConsumer.authState.sendConfirmationCode?.(
|
||||
createAsProvider.authState.confirmationCode!,
|
||||
);
|
||||
|
||||
// Check authorized
|
||||
await waitFor(() => {
|
||||
expect(handleAsConsumer.authState.status).toEqual("authorized");
|
||||
});
|
||||
expect(createAsProvider.authState.status).toEqual("authorized");
|
||||
|
||||
expect(mockAuthenticate).toHaveBeenCalledWith({
|
||||
accountID: expect.stringMatching(/^co_[^/]+$/),
|
||||
accountSecret: expect.stringMatching(
|
||||
/^sealerSecret_[^/]+\/signerSecret_[^/]+$/,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Group } from "../coValues/group";
|
||||
import { Account } from "../exports";
|
||||
import { Account, CoMap } from "../exports";
|
||||
import {
|
||||
co,
|
||||
parseCoValueCreateOptions,
|
||||
parseGroupCreateOptions,
|
||||
waitForCoValueCondition,
|
||||
} from "../internal";
|
||||
import { createJazzTestAccount } from "../testing";
|
||||
|
||||
@@ -88,3 +90,71 @@ describe("parseGroupCreateOptions", () => {
|
||||
expect(result.owner).toBe(account);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForCoValueCondition", () => {
|
||||
class TestCoMap extends CoMap {
|
||||
nothing = co.optional.string;
|
||||
maybeSomething = co.optional.string;
|
||||
something = co.optional.string;
|
||||
}
|
||||
|
||||
let value: TestCoMap;
|
||||
|
||||
beforeEach(() => {
|
||||
value = TestCoMap.create({ something: "world" });
|
||||
});
|
||||
|
||||
it("should resolve", async () => {
|
||||
const { something } = await waitForCoValueCondition(value, {}, (x) =>
|
||||
Boolean(x.something),
|
||||
);
|
||||
|
||||
expect(something).toBe("world");
|
||||
});
|
||||
|
||||
it("should resolve after a delay", async () => {
|
||||
setTimeout(() => {
|
||||
value.maybeSomething = "hi there";
|
||||
}, 50);
|
||||
|
||||
const { maybeSomething } = await waitForCoValueCondition(value, {}, (x) =>
|
||||
Boolean(x.maybeSomething),
|
||||
);
|
||||
|
||||
expect(maybeSomething).toBe("hi there");
|
||||
});
|
||||
|
||||
it("should timeout", async () => {
|
||||
const timeoutMs = 50;
|
||||
|
||||
const promise = waitForCoValueCondition(
|
||||
value,
|
||||
{},
|
||||
(x) => Boolean(x.nothing),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Timeout waiting for CoValue condition",
|
||||
);
|
||||
});
|
||||
|
||||
it("should abort", async () => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const promise = waitForCoValueCondition(
|
||||
value,
|
||||
{ abortSignal: abortController.signal },
|
||||
(x) => Boolean(x.nothing),
|
||||
1000,
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, 50);
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Aborted waiting for CoValue condition",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
75
pnpm-lock.yaml
generated
75
pnpm-lock.yaml
generated
@@ -676,6 +676,67 @@ importers:
|
||||
specifier: 6.0.11
|
||||
version: 6.0.11(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.19.3)(yaml@2.6.1)
|
||||
|
||||
examples/cross-device-account-transfer:
|
||||
dependencies:
|
||||
input-otp:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
jazz-react:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/jazz-react
|
||||
jazz-tools:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/jazz-tools
|
||||
qrcode:
|
||||
specifier: ^1.5.3
|
||||
version: 1.5.4
|
||||
react:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0
|
||||
react-dom:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0(react@19.0.0)
|
||||
react-router:
|
||||
specifier: ^6.16.0
|
||||
version: 6.28.0(react@19.0.0)
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 1.9.4
|
||||
version: 1.9.4
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.5
|
||||
'@types/react':
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0
|
||||
'@types/react-dom':
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.4(vite@6.0.11(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.19.3)(yaml@2.6.1))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.4.20(postcss@8.5.3)
|
||||
globals:
|
||||
specifier: ^15.11.0
|
||||
version: 15.14.0
|
||||
is-ci:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
postcss:
|
||||
specifier: ^8.4.27
|
||||
version: 8.5.3
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.17(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.15.18)(typescript@5.6.2))
|
||||
typescript:
|
||||
specifier: ~5.6.2
|
||||
version: 5.6.2
|
||||
vite:
|
||||
specifier: ^6.0.11
|
||||
version: 6.0.11(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.19.3)(yaml@2.6.1)
|
||||
|
||||
examples/file-share-svelte:
|
||||
dependencies:
|
||||
'@tailwindcss/typography':
|
||||
@@ -1791,6 +1852,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
input-otp:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
jazz-react:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/jazz-react
|
||||
@@ -9988,6 +10052,12 @@ packages:
|
||||
inline-style-prefixer@7.0.1:
|
||||
resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==}
|
||||
|
||||
input-otp@1.4.2:
|
||||
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
|
||||
peerDependencies:
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0
|
||||
|
||||
inquirer@9.3.7:
|
||||
resolution: {integrity: sha512-LJKFHCSeIRq9hanN14IlOtPSTe3lNES7TYDTE2xxdAy1LS5rYphajK1qtwvj3YmQXvvk0U2Vbmcni8P9EIQW9w==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -23796,6 +23866,11 @@ snapshots:
|
||||
dependencies:
|
||||
css-in-js-utils: 3.1.0
|
||||
|
||||
input-otp@1.4.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
inquirer@9.3.7:
|
||||
dependencies:
|
||||
'@inquirer/figures': 1.0.10
|
||||
|
||||
Reference in New Issue
Block a user