Compare commits

...

19 Commits

Author SHA1 Message Date
Guido D'Orsi
df106ca680 test: fix create image test 2025-01-21 11:15:18 +01:00
Guido D'Orsi
c58f93b597 Merge remote-tracking branch 'origin/main' into user-onboarding-upgrade 2025-01-21 10:49:01 +01:00
Guido D'Orsi
3f2a0ead1b fix: self-review fixes 2025-01-21 10:34:42 +01:00
Guido D'Orsi
c9e6d2998e feat: improve the jazz-react provider 2025-01-20 20:10:18 +01:00
Guido D'Orsi
40634c6ec1 chore: update lockfile 2025-01-20 12:41:08 +01:00
Guido D'Orsi
8bc758ce95 Merge remote-tracking branch 'origin/main' into user-onboarding-upgrade 2025-01-20 12:32:25 +01:00
Guido D'Orsi
73079ca1b7 test: update e2e tests 2025-01-17 22:57:45 +01:00
Guido D'Orsi
c36f19a97f fix: fix imports & and tests on createJazzBrowserContext 2025-01-17 19:28:38 +01:00
Guido D'Orsi
109338923a chore: update lockfile 2025-01-17 18:18:57 +01:00
Guido D'Orsi
f4c2501b06 chore: move auth hook in useInJazzAuth 2025-01-17 18:18:24 +01:00
Guido D'Orsi
9161229474 chore: revert the changes on the todo example 2025-01-17 18:17:46 +01:00
Guido D'Orsi
265f2f40bf fix: fix passkey auth cancelation and add tests 2025-01-17 17:50:35 +01:00
Guido D'Orsi
d2935ac2ae feat: make clerk auth compatible with passkeys 2025-01-17 14:49:25 +01:00
Guido D'Orsi
aaf00b8a20 feat: update starter example with anonymous auth 2025-01-17 14:49:25 +01:00
Guido D'Orsi
f9c6a49d2a feat: onboarding auth on todo example 2025-01-17 14:49:25 +01:00
Guido D'Orsi
2f54a48a3d feat: make auth code disappear from the React setup 2025-01-17 14:49:25 +01:00
Guido D'Orsi
be4dd0a787 feat: centralize auth secret management 2025-01-17 14:49:25 +01:00
Guido D'Orsi
f3f072e7cf feat: improve login/signup code and better UX on the music player auth 2025-01-17 14:49:24 +01:00
Guido D'Orsi
a6270bf552 feat: add onboarding user upgrade to Passkey and Clerk auth 2025-01-17 14:49:24 +01:00
61 changed files with 2843 additions and 783 deletions

View File

@@ -13,9 +13,12 @@
"test:ui": "playwright test --ui"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-toast": "^1.1.4",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "workspace:0.9.11",

View File

@@ -11,7 +11,7 @@ import { PlayerControls } from "./components/PlayerControls";
import "./index.css";
import { MusicaAccount } from "@/1_schema";
import { DemoAuthBasicUI, JazzProvider, useDemoAuth } from "jazz-react";
import { JazzProvider } from "jazz-react";
import { useUploadExampleData } from "./lib/useUploadExampleData";
/**
@@ -54,30 +54,11 @@ function Main() {
);
}
function JazzAndAuth({ children }: { children: React.ReactNode }) {
const [auth, state] = useDemoAuth();
const peer =
(new URL(window.location.href).searchParams.get(
"peer",
) as `ws://${string}`) ??
"wss://cloud.jazz.tools/?key=music-player-example-jazz@garden.co";
return (
<>
<JazzProvider
storage="indexedDB"
auth={auth}
peer={peer}
AccountSchema={MusicaAccount}
>
{children}
<JazzInspector />
</JazzProvider>
<DemoAuthBasicUI appName="Jazz Music Player" state={state} />
</>
);
}
const peer =
(new URL(window.location.href).searchParams.get(
"peer",
) as `ws://${string}`) ??
"wss://cloud.jazz.tools/?key=music-player-example-jazz@garden.co";
declare module "jazz-react" {
interface Register {
@@ -87,8 +68,14 @@ declare module "jazz-react" {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<JazzAndAuth>
<JazzProvider
peer={peer}
storage="indexedDB"
localOnly="anonymous" // This makes the app work in local mode when the user is anonymous
AccountSchema={MusicaAccount}
>
<Main />
</JazzAndAuth>
<JazzInspector />
</JazzProvider>
</React.StrictMode>,
);

View File

@@ -1,12 +1,17 @@
import { useToast } from "@/hooks/use-toast";
import { createInviteLink, useAccount, useCoState } from "jazz-react";
import {
createInviteLink,
useAccount,
useCoState,
useIsAnonymousUser,
} from "jazz-react";
import { ID } from "jazz-tools";
import { useNavigate, useParams } from "react-router";
import { Playlist } from "./1_schema";
import { createNewPlaylist, uploadMusicTracks } from "./4_actions";
import { MediaPlayer } from "./5_useMediaPlayer";
import { AuthButton } from "./components/AuthButton";
import { FileUploadButton } from "./components/FileUploadButton";
import { LogoutButton } from "./components/LogoutButton";
import { MusicTrackRow } from "./components/MusicTrackRow";
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
import { SidePanel } from "./components/SidePanel";
@@ -66,6 +71,8 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
});
};
const isAnonymousUser = useIsAnonymousUser();
return (
<div className="flex flex-col h-screen text-gray-800 bg-blue-50">
<div className="flex flex-1 overflow-hidden">
@@ -86,12 +93,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
<Button onClick={handleCreatePlaylist}>New playlist</Button>
</>
)}
{!isRootPlaylist && (
{!isRootPlaylist && !isAnonymousUser && (
<Button onClick={handlePlaylistShareClick}>
Share playlist
</Button>
)}
<LogoutButton />
<AuthButton />
</div>
</div>
<ul className="flex flex-col">

View File

@@ -0,0 +1,40 @@
"use client";
import { Button } from "@/components/ui/button";
import { useAccount, useIsAnonymousUser } from "jazz-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { AuthModal } from "./AuthModal";
export function AuthButton() {
const [open, setOpen] = useState(false);
const { logOut } = useAccount();
const navigate = useNavigate();
const isAnonymousUser = useIsAnonymousUser();
function handleSignOut() {
logOut();
navigate("/");
}
if (!isAnonymousUser) {
return (
<Button variant="outline" onClick={handleSignOut}>
Sign out
</Button>
);
}
return (
<>
<Button
onClick={() => setOpen(true)}
className="bg-white text-black hover:bg-gray-100"
>
Sign up
</Button>
<AuthModal open={open} onOpenChange={setOpen} />
</>
);
}

View File

@@ -0,0 +1,112 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAccount, usePasskeyAuth } from "jazz-react";
import { Loader2 } from "lucide-react";
import { useState } from "react";
interface AuthModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AuthModal({ open, onOpenChange }: AuthModalProps) {
const [username, setUsername] = useState("");
const [isSignUp, setIsSignUp] = useState(true);
const { me } = useAccount();
const [, authState] = usePasskeyAuth({
appName: "Jazz Music Player",
onAnonymousUserUpgrade: ({ username, isSignUp }) => {
if (isSignUp) {
me.profile!.name = username;
}
onOpenChange(false);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (authState.state === "ready") {
if (isSignUp) {
authState.signUp(username);
} else {
authState.logIn();
}
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{isSignUp ? "Create account" : "Welcome back"}
</DialogTitle>
{authState.state === "ready" && (
<DialogDescription>
{isSignUp
? "Sign up to enable network sync and share your playlists with others"
: "Changes done before logging in will be lost"}
</DialogDescription>
)}
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{isSignUp && (
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
/>
</div>
)}
{authState.errors.length > 0 && (
<div className="text-sm text-red-500">
{authState.errors.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
)}
<div className="space-y-4">
<Button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700"
disabled={authState.state === "loading"}
>
{authState.state === "loading" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : isSignUp ? (
"Sign up with passkey"
) : (
"Login with passkey"
)}
</Button>
<div className="text-center text-sm">
{isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
<button
type="button"
onClick={() => setIsSignUp(!isSignUp)}
className="text-blue-600 hover:underline"
>
{isSignUp ? "Login" : "Sign up"}
</button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,37 @@
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsAnonymousUser } from "jazz-react";
import { Info } from "lucide-react";
export function LocalOnlyTag() {
const isAnonymousUser = useIsAnonymousUser();
if (!isAnonymousUser) {
return null;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex items-center gap-1.5 cursor-help">
<Badge variant="default" className="h-5 text-xs font-normal">
Local only
</Badge>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>
Sign up to enable network sync and share your playlists with others
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -1,5 +1,6 @@
import { useAccount } from "jazz-react";
import { useNavigate, useParams } from "react-router";
import { LocalOnlyTag } from "./LocalOnlyTag";
export function SidePanel() {
const { playlistId } = useParams();
@@ -25,7 +26,7 @@ export function SidePanel() {
return (
<aside className="w-64 p-6 bg-white overflow-y-auto">
<div className="flex items-center mb-6">
<div className="flex items-center mb-1">
<svg
className="w-8 h-8 mr-2"
viewBox="0 0 24 24"
@@ -46,6 +47,9 @@ export function SidePanel() {
</svg>
<span className="text-xl font-bold text-blue-600">Music Player</span>
</div>
<div className="mb-6">
<LocalOnlyTag />
</div>
<nav>
<h2 className="mb-2 text-sm font-semibold text-gray-600">Playlists</h2>
<ul className="space-y-1">

View File

@@ -0,0 +1,36 @@
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,120 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,24 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,30 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,44 +1,81 @@
import { test } from "@playwright/test";
import { BrowserContext, test } from "@playwright/test";
import { HomePage } from "./pages/HomePage";
import { LoginPage } from "./pages/LoginPage";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
test("create a new playlist and share", async ({ page }) => {
const loginPage = new LoginPage(page);
async function mockAuthenticator(context: BrowserContext) {
await context.addInitScript(() => {
Object.defineProperty(window.navigator, "credentials", {
value: {
...window.navigator.credentials,
create: async () => ({
type: "public-key",
id: new Uint8Array([1, 2, 3, 4]),
rawId: new Uint8Array([1, 2, 3, 4]),
response: {
clientDataJSON: new Uint8Array([1]),
attestationObject: new Uint8Array([2]),
},
}),
get: async () => ({
type: "public-key",
id: new Uint8Array([1, 2, 3, 4]),
rawId: new Uint8Array([1, 2, 3, 4]),
response: {
authenticatorData: new Uint8Array([1]),
clientDataJSON: new Uint8Array([2]),
signature: new Uint8Array([3]),
},
}),
},
configurable: true,
});
});
}
await loginPage.goto();
await loginPage.fillUsername("S. Mario");
await loginPage.signup();
// Configure the authenticator
test.beforeEach(async ({ context }) => {
// Enable virtual authenticator environment
await mockAuthenticator(context);
});
const homePage = new HomePage(page);
test("create a new playlist and share", async ({
page: marioPage,
browser,
}) => {
await marioPage.goto("/");
const marioHome = new HomePage(marioPage);
// The example song should be loaded
await homePage.expectMusicTrack("Example song");
await homePage.editTrackTitle("Example song", "Super Mario World");
await marioHome.expectMusicTrack("Example song");
await marioHome.editTrackTitle("Example song", "Super Mario World");
await homePage.createPlaylist();
await homePage.editPlaylistTitle("Save the princess");
await marioHome.createPlaylist();
await marioHome.editPlaylistTitle("Save the princess");
await homePage.navigateToPlaylist("All tracks");
await homePage.addTrackToPlaylist("Super Mario World", "Save the princess");
await marioHome.navigateToPlaylist("All tracks");
await marioHome.addTrackToPlaylist("Super Mario World", "Save the princess");
await homePage.navigateToPlaylist("Save the princess");
await homePage.expectMusicTrack("Super Mario World");
await marioHome.navigateToPlaylist("Save the princess");
await marioHome.expectMusicTrack("Super Mario World");
const url = await homePage.getShareLink();
await marioHome.signUp("Mario");
const url = await marioHome.getShareLink();
await sleep(4000); // Wait for the sync to complete
await homePage.logout();
const luigiPage = await (await browser.newContext()).newPage();
await luigiPage.goto("/");
await loginPage.goto();
await loginPage.fillUsername("Luigi");
await loginPage.signup();
const luigiHome = new HomePage(luigiPage);
await page.goto(url);
await luigiHome.signUp("Luigi");
await homePage.expectMusicTrack("Super Mario World");
await homePage.playMusicTrack("Super Mario World");
await homePage.expectActiveTrackPlaying();
await luigiPage.goto(url);
await luigiHome.expectMusicTrack("Super Mario World");
await luigiHome.playMusicTrack("Super Mario World");
await luigiHome.expectActiveTrackPlaying();
});

View File

@@ -95,6 +95,14 @@ export class HomePage {
.click();
}
async signUp(name: string) {
await this.page.getByRole("button", { name: "Sign up" }).click();
await this.page.getByRole("textbox", { name: "Username" }).fill(name);
await this.page
.getByRole("button", { name: "Sign up with passkey" })
.click();
}
async logout() {
await this.logoutButton.click();
}

View File

@@ -1,40 +0,0 @@
import { Locator, Page, expect } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly signupButton: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByRole("textbox");
this.signupButton = page.getByRole("button", {
name: "Sign up",
});
}
async goto() {
this.page.goto("/");
}
async fillUsername(value: string) {
await this.usernameInput.clear();
await this.usernameInput.fill(value);
}
async loginAs(value: string) {
await this.page
.getByRole("button", {
name: value,
})
.click();
}
async signup() {
await this.signupButton.click();
}
async expectLoaded() {
await expect(this.signupButton).toBeVisible();
}
}

View File

@@ -1,8 +1,7 @@
import { AgentSecret } from "cojson";
import { AgentSecret, CryptoProvider } from "cojson";
import { AuthSecretStorage } from "jazz-browser";
import { Account, AuthMethod, AuthResult, Credentials, ID } from "jazz-tools";
const localStorageKey = "jazz-clerk-auth";
export type MinimalClerkClient = {
user:
| {
@@ -21,15 +20,11 @@ export type MinimalClerkClient = {
signOut: () => Promise<void>;
};
function saveCredentialsToLocalStorage(credentials: Credentials) {
localStorage.setItem(
localStorageKey,
JSON.stringify({
accountID: credentials.accountID,
secret: credentials.secret,
}),
);
}
type ClerkCredentials = {
jazzAccountID: ID<Account>;
jazzAccountSecret: AgentSecret;
jazzAccountSeed?: number[];
};
export class BrowserClerkAuth implements AuthMethod {
constructor(
@@ -37,23 +32,28 @@ export class BrowserClerkAuth implements AuthMethod {
private readonly clerkClient: MinimalClerkClient,
) {}
async start(): Promise<AuthResult> {
// Check local storage for credentials
const locallyStoredCredentials = localStorage.getItem(localStorageKey);
async start(crypto: CryptoProvider): Promise<AuthResult> {
AuthSecretStorage.migrate();
if (locallyStoredCredentials) {
// Check local storage for credentials
const credentials = AuthSecretStorage.get();
const isAnonymous = AuthSecretStorage.isAnonymous();
if (credentials && !isAnonymous) {
try {
const credentials = JSON.parse(locallyStoredCredentials) as Credentials;
return {
type: "existing",
credentials,
credentials: {
accountID: credentials.accountID,
secret: credentials.accountSecret,
},
saveCredentials: async () => {}, // No need to save credentials when recovering from local storage
onSuccess: () => {},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
localStorage.removeItem(localStorageKey);
AuthSecretStorage.clear();
void this.clerkClient.signOut();
},
};
@@ -63,22 +63,65 @@ export class BrowserClerkAuth implements AuthMethod {
}
if (this.clerkClient.user) {
const username =
this.clerkClient.user.fullName ||
this.clerkClient.user.username ||
this.clerkClient.user.id;
// Check clerk user metadata for credentials
const storedCredentials = this.clerkClient.user.unsafeMetadata;
if (storedCredentials.jazzAccountID) {
if (!storedCredentials.jazzAccountSecret) {
const clerkCredentials = this.clerkClient.user
.unsafeMetadata as ClerkCredentials;
if (clerkCredentials.jazzAccountID) {
if (!clerkCredentials.jazzAccountSecret) {
this.driver.onError("No secret for existing user");
throw new Error("No secret for existing user");
}
return {
type: "existing",
credentials: {
accountID: storedCredentials.jazzAccountID as ID<Account>,
secret: storedCredentials.jazzAccountSecret as AgentSecret,
accountID: clerkCredentials.jazzAccountID as ID<Account>,
secret: clerkCredentials.jazzAccountSecret as AgentSecret,
},
saveCredentials: async ({ accountID, secret }: Credentials) => {
saveCredentialsToLocalStorage({
AuthSecretStorage.set({
accountID,
secret,
accountSecret: secret,
secretSeed: clerkCredentials.jazzAccountSeed
? Uint8Array.from(clerkCredentials.jazzAccountSeed)
: undefined,
provider: "clerk",
});
},
onSuccess: () => {},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
void this.clerkClient.signOut();
},
};
} else if (credentials && isAnonymous) {
return {
type: "existing",
username,
credentials: {
accountID: credentials.accountID,
secret: credentials.accountSecret,
},
saveCredentials: async ({ accountID, secret }: Credentials) => {
AuthSecretStorage.set({
accountID,
accountSecret: secret,
secretSeed: credentials.secretSeed,
provider: "clerk",
});
await this.clerkClient.user?.update({
unsafeMetadata: {
jazzAccountID: accountID,
jazzAccountSecret: secret,
jazzAccountSeed: credentials.secretSeed
? Array.from(credentials.secretSeed)
: undefined,
} satisfies ClerkCredentials,
});
},
onSuccess: () => {},
@@ -90,25 +133,27 @@ export class BrowserClerkAuth implements AuthMethod {
},
};
} else {
const secretSeed = crypto.newRandomSecretSeed();
// No credentials found, so we need to create new credentials
return {
type: "new",
creationProps: {
name:
this.clerkClient.user.fullName ||
this.clerkClient.user.username ||
this.clerkClient.user.id,
name: username,
},
initialSecret: crypto.agentSecretFromSecretSeed(secretSeed),
saveCredentials: async ({ accountID, secret }: Credentials) => {
saveCredentialsToLocalStorage({
AuthSecretStorage.set({
accountID,
secret,
secretSeed,
accountSecret: secret,
provider: "clerk",
});
await this.clerkClient.user?.update({
unsafeMetadata: {
jazzAccountID: accountID,
jazzAccountSecret: secret,
},
jazzAccountSeed: Array.from(secretSeed),
} satisfies ClerkCredentials,
});
},
onSuccess: () => {},

View File

@@ -1,119 +1,212 @@
// @vitest-environment happy-dom
import { AgentSecret } from "cojson";
import { Account, ID } from "jazz-tools";
import { AuthSecretStorage } from "jazz-browser";
import { Account } from "jazz-tools";
import { ID } from "jazz-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BrowserClerkAuth, MinimalClerkClient } from "../index.js";
import { BrowserClerkAuth } from "../index";
beforeEach(() => {
AuthSecretStorage.clear();
});
describe("BrowserClerkAuth", () => {
let mockLocalStorage: { [key: string]: string };
let mockClerkClient: MinimalClerkClient;
let mockDriver: BrowserClerkAuth.Driver;
function createDriver() {
return {
onError: vi.fn(),
} satisfies BrowserClerkAuth.Driver;
}
beforeEach(() => {
// Mock localStorage
mockLocalStorage = {};
global.localStorage = {
getItem: vi.fn((key: string) => mockLocalStorage[key] || null),
setItem: vi.fn((key: string, value: string) => {
mockLocalStorage[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete mockLocalStorage[key];
}),
clear: vi.fn(),
length: 0,
key: vi.fn(),
};
// Mock Clerk client
mockClerkClient = {
user: {
unsafeMetadata: {},
fullName: "Test User",
username: "testuser",
id: "test-id",
update: vi.fn(),
},
function createMockClerkClient(user?: any) {
return {
user,
signOut: vi.fn(),
};
}
// Mock driver
mockDriver = {
onError: vi.fn(),
};
});
describe("initialization", () => {
it("should handle existing non-anonymous user from storage", async () => {
const driver = createDriver();
const mockClerkClient = createMockClerkClient();
const auth = new BrowserClerkAuth(driver, mockClerkClient);
describe("clerk credentials in localStorage", () => {
it("should get credentials from localStorage when clerk user is not signed in", async () => {
mockLocalStorage["jazz-clerk-auth"] = JSON.stringify({
accountID: "test-account-id",
accountSecret: "test-secret",
// Set up existing user in storage
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1]),
accountSecret: "fromAuthStorage" as AgentSecret,
provider: "clerk",
});
const auth = new BrowserClerkAuth(mockDriver, {
...mockClerkClient,
user: null,
});
const result = await auth.start();
expect(result.type).toBe("existing");
});
});
describe("clerk credentials not in localStorage", () => {
it("should return new credentials when clerk user signs up", async () => {
const auth = new BrowserClerkAuth(mockDriver, mockClerkClient);
const result = await auth.start();
expect(result.type).toBe("new");
});
it("should return existing credentials when clerk user is signed in", async () => {
mockClerkClient = {
user: {
unsafeMetadata: {
jazzAccountID: "test-account-id",
jazzAccountSecret: "test-secret",
},
fullName: "Test User",
username: "testuser",
id: "test-id",
update: vi.fn(),
},
signOut: vi.fn(),
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([2]),
agentSecretFromSecretSeed: () => "xxxxx" as AgentSecret,
};
const auth = new BrowserClerkAuth(mockDriver, mockClerkClient);
const result = await auth.start();
const result = await auth.start(mockCrypto as any);
expect(result.type).toBe("existing");
if (result.type !== "existing") {
throw new Error("Expected existing user login");
}
expect(result.credentials).toEqual({
accountID: "test123",
secret: "fromAuthStorage",
});
});
it("should handle existing clerk user with credentials", async () => {
const driver = createDriver();
const mockUser = {
id: "clerk123",
fullName: "Test User",
unsafeMetadata: {
jazzAccountID: "test123",
jazzAccountSecret: "clerkSecret",
jazzAccountSeed: [1, 2, 3],
},
update: vi.fn(),
};
const mockClerkClient = createMockClerkClient(mockUser);
const auth = new BrowserClerkAuth(driver, mockClerkClient);
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([2]),
agentSecretFromSecretSeed: () => "xxxxx" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
expect(result.type).toBe("existing");
if (result.type !== "existing") {
throw new Error("Expected existing user login");
}
expect(result.credentials).toEqual({
accountID: "test123",
secret: "clerkSecret",
});
});
it("should handle anonymous user upgrade", async () => {
const driver = createDriver();
const mockUser = {
id: "clerk123",
fullName: "Test User",
unsafeMetadata: {},
update: vi.fn(),
};
const mockClerkClient = createMockClerkClient(mockUser);
const auth = new BrowserClerkAuth(driver, mockClerkClient);
// Set up anonymous user in storage
AuthSecretStorage.set({
accountID: "anon123" as ID<Account>,
secretSeed: new Uint8Array([1]),
accountSecret: "anonSecret" as AgentSecret,
provider: "anonymous",
});
const result = await auth.start({} as any);
expect(result.type).toBe("existing");
if (result.type !== "existing") {
throw new Error("Expected existing user login");
}
expect(result.username).toBe("Test User");
expect(result.credentials).toEqual({
accountID: "anon123",
secret: "anonSecret",
});
// Test saving credentials updates both storage and clerk metadata
await result.saveCredentials?.({
accountID: "anon123" as ID<Account>,
secret: "newSecret" as AgentSecret,
});
expect(mockUser.update).toHaveBeenCalledWith({
unsafeMetadata: {
jazzAccountID: "anon123",
jazzAccountSecret: "newSecret",
jazzAccountSeed: expect.any(Array),
},
});
});
it("should handle new user creation", async () => {
const driver = createDriver();
const mockUser = {
id: "clerk123",
fullName: "Test User",
unsafeMetadata: {},
update: vi.fn(),
};
const mockClerkClient = createMockClerkClient(mockUser);
const auth = new BrowserClerkAuth(driver, mockClerkClient);
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "newSecret" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
expect(result.type).toBe("new");
if (result.type !== "new") {
throw new Error("Expected new user login");
}
expect(result.creationProps).toEqual({
name: "Test User",
});
expect(result.initialSecret).toBe("newSecret");
await result.saveCredentials({
accountID: "new123" as ID<Account>,
secret: "newSecret" as AgentSecret,
});
expect(mockUser.update).toHaveBeenCalledWith({
unsafeMetadata: {
jazzAccountID: "new123",
jazzAccountSecret: "newSecret",
jazzAccountSeed: [1, 2, 3],
},
});
});
it("should throw error when not signed in", async () => {
const auth = new BrowserClerkAuth(mockDriver, {
...mockClerkClient,
user: null,
});
const driver = createDriver();
const mockClerkClient = createMockClerkClient(undefined);
const auth = new BrowserClerkAuth(driver, mockClerkClient);
await expect(auth.start()).rejects.toThrow("Not signed in");
await expect(auth.start({} as any)).rejects.toThrow("Not signed in");
});
it("should save credentials to localStorage", async () => {
const auth = new BrowserClerkAuth(mockDriver, mockClerkClient);
const result = await auth.start();
if (result.saveCredentials) {
await result.saveCredentials({
accountID: "test-account-id" as ID<Account>,
secret: "test-secret" as AgentSecret,
});
}
it("should throw error when clerk user has ID but no secret", async () => {
const driver = createDriver();
const mockUser = {
id: "clerk123",
fullName: "Test User",
unsafeMetadata: {
jazzAccountID: "test123",
},
update: vi.fn(),
};
const mockClerkClient = createMockClerkClient(mockUser);
const auth = new BrowserClerkAuth(driver, mockClerkClient);
expect(mockLocalStorage["jazz-clerk-auth"]).toBeDefined();
});
it("should call clerk signOut when logging out", async () => {
const auth = new BrowserClerkAuth(mockDriver, mockClerkClient);
const result = await auth.start();
result.logOut();
expect(mockClerkClient.signOut).toHaveBeenCalled();
await expect(auth.start({} as any)).rejects.toThrow(
"No secret for existing user",
);
});
});
});

View File

@@ -44,6 +44,7 @@ vi.mock("image-blob-reduce", () => {
describe("createImage", () => {
it("should create an image definition with correct dimensions", async () => {
vi.useFakeTimers();
// Create a test blob that simulates a 400x300 image
const blob = new Blob(["fake-image-data"], { type: "image/jpeg" });
Object.defineProperty(blob, "size", { value: 1024 * 50 }); // 50KB

View File

@@ -7,10 +7,13 @@
"license": "MIT",
"dependencies": {
"@scure/bip39": "^1.3.0",
"cojson": "workspace:0.9.11",
"cojson-storage-indexeddb": "workspace:0.9.11",
"cojson-transport-ws": "workspace:0.9.11",
"jazz-tools": "workspace:0.9.11",
"cojson": "workspace:*",
"cojson-storage-indexeddb": "workspace:*",
"cojson-transport-ws": "workspace:*",
"jazz-tools": "workspace:*"
},
"devDependencies": {
"fake-indexeddb": "^6.0.0",
"typescript": "~5.6.2"
},
"scripts": {

View File

@@ -1,43 +1,37 @@
import { AgentSecret } from "cojson";
import { AgentSecret, CryptoProvider } from "cojson";
import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
type StorageData = {
accountID: ID<Account>;
accountSecret: AgentSecret;
};
const STORAGE_KEY = "jazz-logged-in-secret";
import { AuthSecretStorage } from "./AuthSecretStorage.js";
/**
* `BrowserOnboardingAuth` provides a `JazzAuth` object for demo authentication.
* `BrowserAnonymousAuth` provides a `JazzAuth` object for demo authentication.
*
* Demo authentication is useful for quickly testing your app, as it allows you to create new accounts and log in as existing ones. The authentication persists across page reloads, with the credentials stored in `localStorage`.
*
* ```
* import { BrowserOnboardingAuth } from "jazz-browser";
* import { BrowserAnonymousAuth } from "jazz-browser";
*
* const auth = new BrowserOnboardingAuth(driver);
* const auth = new BrowserAnonymousAuth(driver);
* ```
*
* @category Auth Providers
*/
export class BrowserOnboardingAuth implements AuthMethod {
export class BrowserAnonymousAuth implements AuthMethod {
constructor(
public defaultUserName: string,
public driver: BrowserOnboardingAuth.Driver,
public driver: BrowserAnonymousAuth.Driver,
) {}
/**
* @returns A `JazzAuth` object
*/
async start() {
const existingUser = localStorage[STORAGE_KEY];
async start(crypto: CryptoProvider) {
AuthSecretStorage.migrate();
const existingUser = AuthSecretStorage.get();
if (existingUser) {
const existingUserData = JSON.parse(existingUser) as StorageData;
const accountID = existingUserData.accountID as ID<Account>;
const secret = existingUserData.accountSecret;
const accountID = existingUser.accountID;
const secret = existingUser.accountSecret;
return {
type: "existing",
@@ -51,19 +45,22 @@ export class BrowserOnboardingAuth implements AuthMethod {
logOut,
} satisfies AuthResult;
} else {
const secretSeed = crypto.newRandomSecretSeed();
return {
type: "new",
creationProps: { name: this.defaultUserName, anonymous: true },
initialSecret: crypto.agentSecretFromSecretSeed(secretSeed),
saveCredentials: async (credentials: {
accountID: ID<Account>;
secret: AgentSecret;
}) => {
const storageData = JSON.stringify({
AuthSecretStorage.set({
accountID: credentials.accountID,
secretSeed,
accountSecret: credentials.secret,
} satisfies StorageData);
localStorage[STORAGE_KEY] = storageData;
provider: "anonymous",
});
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
@@ -79,7 +76,7 @@ export class BrowserOnboardingAuth implements AuthMethod {
/** @internal */
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace BrowserOnboardingAuth {
export namespace BrowserAnonymousAuth {
export interface Driver {
onSignedIn: (next: { logOut: () => void }) => void;
onError: (error: string | Error) => void;
@@ -87,5 +84,5 @@ export namespace BrowserOnboardingAuth {
}
function logOut() {
delete localStorage[STORAGE_KEY];
AuthSecretStorage.clear();
}

View File

@@ -0,0 +1,96 @@
import { AgentSecret } from "cojson";
import { Account, ID } from "jazz-tools";
const STORAGE_KEY = "jazz-logged-in-secret";
export type AuthCredentials = {
accountID: ID<Account>;
secretSeed?: Uint8Array;
accountSecret: AgentSecret;
provider?: "anonymous" | "clerk" | "demo" | "passkey" | "passphrase" | string;
};
export type AuthSetPayload = {
accountID: ID<Account>;
secretSeed?: Uint8Array;
accountSecret: AgentSecret;
provider: "anonymous" | "clerk" | "demo" | "passkey" | "passphrase" | string;
};
export const AuthSecretStorage = {
migrate() {
if (!localStorage[STORAGE_KEY]) {
const demoAuthSecret = localStorage["demo-auth-logged-in-secret"];
if (demoAuthSecret) {
localStorage[STORAGE_KEY] = demoAuthSecret;
delete localStorage["demo-auth-logged-in-secret"];
}
const clerkAuthSecret = localStorage["jazz-clerk-auth"];
if (clerkAuthSecret) {
localStorage[STORAGE_KEY] = clerkAuthSecret;
delete localStorage["jazz-clerk-auth"];
}
}
},
get(): AuthCredentials | null {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return null;
const parsed = JSON.parse(data);
if (!parsed.accountID || !parsed.accountSecret) {
throw new Error("Invalid auth secret storage data");
}
return {
accountID: parsed.accountID,
secretSeed: parsed.secretSeed
? new Uint8Array(parsed.secretSeed)
: undefined,
accountSecret: parsed.accountSecret,
provider: parsed.provider,
};
},
set(payload: AuthSetPayload) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
accountID: payload.accountID,
secretSeed: payload.secretSeed
? Array.from(payload.secretSeed)
: undefined,
accountSecret: payload.accountSecret,
provider: payload.provider,
}),
);
this.emitUpdate();
},
isAnonymous() {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return false;
const parsed = JSON.parse(data);
return parsed.provider === "anonymous";
},
onUpdate(handler: () => void) {
window.addEventListener("jazz-auth-update", handler);
return () => window.removeEventListener("jazz-auth-update", handler);
},
emitUpdate() {
window.dispatchEvent(new Event("jazz-auth-update"));
},
clear() {
localStorage.removeItem(STORAGE_KEY);
this.emitUpdate();
},
};

View File

@@ -1,13 +1,12 @@
import { AgentSecret } from "cojson";
import { AgentSecret, CryptoProvider } from "cojson";
import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
import { AuthSecretStorage } from "./AuthSecretStorage.js";
type StorageData = {
accountID: ID<Account>;
accountSecret: AgentSecret;
};
const localStorageKey = "jazz-logged-in-secret";
/**
* `BrowserDemoAuth` provides a `JazzAuth` object for demo authentication.
*
@@ -53,44 +52,39 @@ export class BrowserDemoAuth implements AuthMethod {
/**
* @returns A `JazzAuth` object
*/
async start() {
// migrate old localStorage key to new one
if (localStorage["demo-auth-logged-in-secret"]) {
if (!localStorage[localStorageKey]) {
localStorage[localStorageKey] =
localStorage["demo-auth-logged-in-secret"];
}
delete localStorage["demo-auth-logged-in-secret"];
}
async start(crypto: CryptoProvider) {
AuthSecretStorage.migrate();
if (localStorage[localStorageKey]) {
const localStorageData = JSON.parse(
localStorage[localStorageKey],
) as StorageData;
const credentials = AuthSecretStorage.get();
const accountID = localStorageData.accountID as ID<Account>;
const secret = localStorageData.accountSecret;
if (credentials) {
const accountID = credentials.accountID;
const secret = credentials.accountSecret;
return {
type: "existing",
credentials: { accountID, secret },
onSuccess: () => {
this.driver.onSignedIn({ logOut });
this.driver.onSignedIn({ logOut, isSignUp: false });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
delete localStorage[localStorageKey];
AuthSecretStorage.clear();
},
} satisfies AuthResult;
} else {
return new Promise<AuthResult>((resolve) => {
this.driver.onReady({
signUp: async (username) => {
const secretSeed = crypto.newRandomSecretSeed();
const accountSecret = crypto.agentSecretFromSecretSeed(secretSeed);
resolve({
type: "new",
creationProps: { name: username },
initialSecret: accountSecret,
saveCredentials: async (credentials: {
accountID: ID<Account>;
secret: AgentSecret;
@@ -100,7 +94,13 @@ export class BrowserDemoAuth implements AuthMethod {
accountSecret: credentials.secret,
} satisfies StorageData);
localStorage[localStorageKey] = storageData;
AuthSecretStorage.set({
accountID: credentials.accountID,
secretSeed,
accountSecret,
provider: "demo",
});
localStorage["demo-auth-existing-users-" + username] =
storageData;
@@ -111,13 +111,13 @@ export class BrowserDemoAuth implements AuthMethod {
: username;
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
this.driver.onSignedIn({ logOut, isSignUp: true });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
delete localStorage[localStorageKey];
AuthSecretStorage.clear();
},
});
},
@@ -128,7 +128,11 @@ export class BrowserDemoAuth implements AuthMethod {
localStorage["demo-auth-existing-users-" + existingUser],
) as StorageData;
localStorage[localStorageKey] = JSON.stringify(storageData);
AuthSecretStorage.set({
accountID: storageData.accountID,
accountSecret: storageData.accountSecret,
provider: "demo",
});
resolve({
type: "existing",
@@ -137,13 +141,13 @@ export class BrowserDemoAuth implements AuthMethod {
secret: storageData.accountSecret,
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
this.driver.onSignedIn({ logOut, isSignUp: false });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
delete localStorage[localStorageKey];
AuthSecretStorage.clear();
},
});
},
@@ -162,11 +166,14 @@ export namespace BrowserDemoAuth {
existingUsers: string[];
logInAs: (existingUser: string) => Promise<void>;
}) => void;
onSignedIn: (next: { logOut: () => void }) => void;
onSignedIn: (next: {
logOut: () => void;
isSignUp: boolean;
}) => void;
onError: (error: string | Error) => void;
}
}
function logOut() {
delete localStorage[localStorageKey];
AuthSecretStorage.clear();
}

View File

@@ -1,17 +1,6 @@
import {
AgentSecret,
CryptoProvider,
RawAccountID,
cojsonInternals,
} from "cojson";
import { CryptoProvider, RawAccountID, cojsonInternals } from "cojson";
import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
type LocalStorageData = {
accountID: ID<Account>;
accountSecret: AgentSecret;
};
const localStorageKey = "jazz-logged-in-secret";
import { AuthSecretStorage } from "./AuthSecretStorage.js";
/**
* `BrowserPasskeyAuth` provides a `JazzAuth` object for passkey authentication.
@@ -32,25 +21,18 @@ export class BrowserPasskeyAuth implements AuthMethod {
public appHostname: string = window.location.hostname,
) {}
accountLoaded() {
this.driver.onSignedIn({ logOut });
}
onError(error: string | Error) {
this.driver.onError(error);
}
/**
* @returns A `JazzAuth` object
*/
async start(crypto: CryptoProvider): Promise<AuthResult> {
if (localStorage[localStorageKey]) {
const localStorageData = JSON.parse(
localStorage[localStorageKey],
) as LocalStorageData;
AuthSecretStorage.migrate();
const accountID = localStorageData.accountID as ID<Account>;
const secret = localStorageData.accountSecret;
const credentials = AuthSecretStorage.get();
const isAnonymous = AuthSecretStorage.isAnonymous();
if (credentials && !isAnonymous) {
const accountID = credentials.accountID;
const secret = credentials.accountSecret;
return {
type: "existing",
@@ -62,85 +44,96 @@ export class BrowserPasskeyAuth implements AuthMethod {
this.driver.onError(error);
},
logOut: () => {
delete localStorage[localStorageKey];
AuthSecretStorage.clear();
},
} satisfies AuthResult;
} else {
return new Promise<AuthResult>((resolve) => {
this.driver.onReady({
signUp: async (username) => {
const secretSeed = crypto.newRandomSecretSeed();
if (credentials && isAnonymous && credentials.secretSeed) {
const secretSeed = credentials.secretSeed;
resolve({
type: "new",
creationProps: { name: username },
initialSecret: crypto.agentSecretFromSecretSeed(secretSeed),
saveCredentials: async ({ accountID, secret }) => {
const webAuthNCredentialPayload = new Uint8Array(
cojsonInternals.secretSeedLength +
cojsonInternals.shortHashLength,
);
resolve({
type: "existing",
username,
credentials: {
accountID: credentials.accountID,
secret: credentials.accountSecret,
},
saveCredentials: async ({ accountID, secret }) => {
await this.createPasskeyCredentials({
accountID,
secretSeed,
username,
});
webAuthNCredentialPayload.set(secretSeed);
webAuthNCredentialPayload.set(
cojsonInternals.rawCoIDtoBytes(
accountID as unknown as RawAccountID,
),
cojsonInternals.secretSeedLength,
);
AuthSecretStorage.set({
accountID,
secretSeed,
accountSecret: secret,
provider: "passkey",
});
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut,
});
return;
} else {
const secretSeed = crypto.newRandomSecretSeed();
await navigator.credentials.create({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rp: {
name: this.appName,
id: this.appHostname,
},
user: {
id: webAuthNCredentialPayload,
name: username + ` (${new Date().toLocaleString()})`,
displayName: username,
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
authenticatorSelection: {
authenticatorAttachment: "platform",
requireResidentKey: true,
residentKey: "required",
},
timeout: 60000,
attestation: "direct",
},
});
resolve({
type: "new",
creationProps: { name: username },
initialSecret: crypto.agentSecretFromSecretSeed(secretSeed),
saveCredentials: async ({ accountID, secret }) => {
await this.createPasskeyCredentials({
accountID,
secretSeed,
username,
});
localStorage[localStorageKey] = JSON.stringify({
accountID,
accountSecret: secret,
} satisfies LocalStorageData);
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
delete localStorage[localStorageKey];
},
});
AuthSecretStorage.set({
accountID,
secretSeed,
accountSecret: secret,
provider: "passkey",
});
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
},
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut,
});
}
},
logIn: async () => {
const webAuthNCredential = (await navigator.credentials.get({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rpId: this.appHostname,
allowCredentials: [],
timeout: 60000,
const webAuthNCredential = await this.getPasskeyCredentials().catch(
() => {
this.driver.onError(
"Error while accessing the passkey credentials",
);
return "rejected" as const;
},
})) as unknown as {
response: { userHandle: ArrayBuffer };
};
);
if (webAuthNCredential === "rejected") {
return;
}
if (!webAuthNCredential) {
throw new Error("Couldn't log in");
this.driver.onError(
"Error while accessing the passkey credentials",
);
return;
}
const webAuthNCredentialPayload = new Uint8Array(
@@ -165,10 +158,12 @@ export class BrowserPasskeyAuth implements AuthMethod {
type: "existing",
credentials: { accountID, secret },
saveCredentials: async ({ accountID, secret }) => {
localStorage[localStorageKey] = JSON.stringify({
AuthSecretStorage.set({
accountID,
accountSecret: secret,
} satisfies LocalStorageData);
secretSeed: accountSecretSeed,
provider: "passkey",
});
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
@@ -176,15 +171,79 @@ export class BrowserPasskeyAuth implements AuthMethod {
onError: (error: string | Error) => {
this.driver.onError(error);
},
logOut: () => {
delete localStorage[localStorageKey];
},
logOut,
});
},
});
});
}
}
private async createPasskeyCredentials({
accountID,
secretSeed,
username,
}: {
accountID: ID<Account>;
secretSeed: Uint8Array;
username: string;
}) {
const webAuthNCredentialPayload = new Uint8Array(
cojsonInternals.secretSeedLength + cojsonInternals.shortHashLength,
);
webAuthNCredentialPayload.set(secretSeed);
webAuthNCredentialPayload.set(
cojsonInternals.rawCoIDtoBytes(accountID as unknown as RawAccountID),
cojsonInternals.secretSeedLength,
);
try {
await navigator.credentials.create({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rp: {
name: this.appName,
id: this.appHostname,
},
user: {
id: webAuthNCredentialPayload,
name: username + ` (${new Date().toLocaleString()})`,
displayName: username,
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
authenticatorSelection: {
authenticatorAttachment: "platform",
requireResidentKey: true,
residentKey: "required",
},
timeout: 60000,
attestation: "direct",
},
});
} catch (error) {
if (error instanceof DOMException && error.name === "NotAllowedError") {
throw new Error("Passkey creation not allowed");
}
throw error;
}
}
private async getPasskeyCredentials() {
const value = await navigator.credentials.get({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rpId: this.appHostname,
allowCredentials: [],
timeout: 60000,
},
});
return value as
| (Credential & { response: { userHandle: ArrayBuffer } })
| null;
}
}
/** @internal */
@@ -201,5 +260,5 @@ export namespace BrowserPasskeyAuth {
}
function logOut() {
delete localStorage[localStorageKey];
AuthSecretStorage.clear();
}

View File

@@ -1,13 +1,7 @@
import * as bip39 from "@scure/bip39";
import { AgentSecret, CryptoProvider, cojsonInternals } from "cojson";
import { CryptoProvider, cojsonInternals } from "cojson";
import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
type LocalStorageData = {
accountID: ID<Account>;
accountSecret: AgentSecret;
};
const localStorageKey = "jazz-logged-in-secret";
import { AuthSecretStorage } from "./AuthSecretStorage.js";
/**
* `BrowserPassphraseAuth` provides a `JazzAuth` object for passphrase authentication.
@@ -15,7 +9,7 @@ const localStorageKey = "jazz-logged-in-secret";
* ```ts
* import { BrowserPassphraseAuth } from "jazz-browser";
*
* const auth = new BrowserPassphraseAuth(driver, wordlist, appName);
* const auth = new BrowserPassphraseAuth(driver, wordlist);
* ```
*
* @category Auth Providers
@@ -24,22 +18,20 @@ export class BrowserPassphraseAuth implements AuthMethod {
constructor(
public driver: BrowserPassphraseAuth.Driver,
public wordlist: string[],
public appName: string,
// TODO: is this a safe default?
public appHostname: string = window.location.hostname,
) {}
/**
* @returns A `JazzAuth` object
*/
async start(crypto: CryptoProvider): Promise<AuthResult> {
if (localStorage[localStorageKey]) {
const localStorageData = JSON.parse(
localStorage[localStorageKey],
) as LocalStorageData;
AuthSecretStorage.migrate();
const accountID = localStorageData.accountID as ID<Account>;
const secret = localStorageData.accountSecret;
const credentials = AuthSecretStorage.get();
const isAnonymous = AuthSecretStorage.isAnonymous();
if (credentials && !isAnonymous) {
const accountID = credentials.accountID;
const secret = credentials.accountSecret;
return {
type: "existing",
@@ -51,13 +43,19 @@ export class BrowserPassphraseAuth implements AuthMethod {
this.driver.onError(error);
},
logOut: () => {
delete localStorage[localStorageKey];
AuthSecretStorage.clear();
},
} satisfies AuthResult;
} else {
return new Promise<AuthResult>((resolve) => {
this.driver.onReady({
signUp: async (username, passphrase) => {
if (credentials && isAnonymous) {
console.warn(
"Anonymous user upgrade is currently not supported on passphrase auth",
);
}
const secretSeed = bip39.mnemonicToEntropy(
passphrase,
this.wordlist,
@@ -73,10 +71,12 @@ export class BrowserPassphraseAuth implements AuthMethod {
creationProps: { name: username },
initialSecret: accountSecret,
saveCredentials: async (credentials) => {
localStorage[localStorageKey] = JSON.stringify({
AuthSecretStorage.set({
accountID: credentials.accountID,
accountSecret: credentials.secret,
} satisfies LocalStorageData);
secretSeed,
accountSecret,
provider: "passphrase",
});
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
@@ -85,7 +85,7 @@ export class BrowserPassphraseAuth implements AuthMethod {
this.driver.onError(error);
},
logOut: () => {
delete localStorage[localStorageKey];
AuthSecretStorage.clear();
},
});
},
@@ -112,11 +112,13 @@ export class BrowserPassphraseAuth implements AuthMethod {
resolve({
type: "existing",
credentials: { accountID, secret: accountSecret },
saveCredentials: async ({ accountID, secret }) => {
localStorage[localStorageKey] = JSON.stringify({
saveCredentials: async ({ accountID }) => {
AuthSecretStorage.set({
accountID,
accountSecret: secret,
} satisfies LocalStorageData);
secretSeed,
accountSecret,
provider: "passphrase",
});
},
onSuccess: () => {
this.driver.onSignedIn({ logOut });
@@ -125,7 +127,7 @@ export class BrowserPassphraseAuth implements AuthMethod {
this.driver.onError(error);
},
logOut: () => {
delete localStorage[localStorageKey];
AuthSecretStorage.clear();
},
});
},
@@ -148,5 +150,5 @@ export namespace BrowserPassphraseAuth {
}
function logOut() {
delete localStorage[localStorageKey];
AuthSecretStorage.clear();
}

View File

@@ -1,17 +1,13 @@
import { Peer } from "cojson";
import { createWebSocketPeer } from "cojson-transport-ws";
import type { Peer } from "jazz-tools";
export function createWebSocketPeerWithReconnection(
peer: string,
reconnectionTimeout: number | undefined,
addPeer: (peer: Peer) => void,
removePeer: (peer: Peer) => void,
) {
const firstWsPeer = createWebSocketPeer({
websocket: new WebSocket(peer),
id: peer,
role: "server",
onClose: reconnectWebSocket,
});
let currentPeer: Peer | undefined = undefined;
let shouldTryToReconnect = true;
let currentReconnectionTimeout = reconnectionTimeout || 500;
@@ -26,35 +22,49 @@ export function createWebSocketPeerWithReconnection(
async function reconnectWebSocket() {
if (!shouldTryToReconnect) return;
console.log(
"Websocket disconnected, trying to reconnect in " +
currentReconnectionTimeout +
"ms",
);
currentReconnectionTimeout = Math.min(
currentReconnectionTimeout * 2,
30000,
);
if (currentPeer) {
removePeer(currentPeer);
await waitForOnline(currentReconnectionTimeout);
console.log(
"Websocket disconnected, trying to reconnect in " +
currentReconnectionTimeout +
"ms",
);
currentReconnectionTimeout = Math.min(
currentReconnectionTimeout * 2,
30000,
);
await waitForOnline(currentReconnectionTimeout);
}
if (!shouldTryToReconnect) return;
addPeer(
createWebSocketPeer({
websocket: new WebSocket(peer),
id: peer,
role: "server",
onClose: reconnectWebSocket,
}),
);
currentPeer = createWebSocketPeer({
websocket: new WebSocket(peer),
id: peer,
role: "server",
onClose: reconnectWebSocket,
});
addPeer(currentPeer);
}
return {
peer: firstWsPeer,
done: () => {
enable: () => {
shouldTryToReconnect = true;
if (!currentPeer) {
reconnectWebSocket();
}
},
disable: () => {
shouldTryToReconnect = false;
window.removeEventListener("online", onOnline);
if (currentPeer) {
removePeer(currentPeer);
currentPeer = undefined;
}
},
};
}

View File

@@ -18,10 +18,12 @@ import {
import { OPFSFilesystem } from "./OPFSFilesystem.js";
import { createWebSocketPeerWithReconnection } from "./createWebSocketPeerWithReconnection.js";
import { StorageConfig, getStorageOptions } from "./storageOptions.js";
export { AuthSecretStorage } from "./auth/AuthSecretStorage.js";
export { BrowserDemoAuth } from "./auth/DemoAuth.js";
export { BrowserPasskeyAuth } from "./auth/PasskeyAuth.js";
export { BrowserPassphraseAuth } from "./auth/PassphraseAuth.js";
export { BrowserOnboardingAuth } from "./auth/OnboardingAuth.js";
export { BrowserAnonymousAuth } from "./auth/AnonymousAuth.js";
import { BrowserAnonymousAuth } from "./auth/AnonymousAuth.js";
import { setupInspector } from "./utils/export-account-inspector.js";
setupInspector();
@@ -29,6 +31,7 @@ setupInspector();
/** @category Context Creation */
export type BrowserContext<Acc extends Account> = {
me: Acc;
toggleNetwork: (enabled: boolean) => void;
logOut: () => void;
// TODO: Symbol.dispose?
done: () => void;
@@ -36,15 +39,21 @@ export type BrowserContext<Acc extends Account> = {
export type BrowserGuestContext = {
guest: AnonymousJazzAgent;
toggleNetwork: (enabled: boolean) => void;
logOut: () => void;
done: () => void;
};
export type BrowserContextOptions<Acc extends Account> = {
auth: AuthMethod;
AccountSchema: CoValueClass<Acc> & {
auth?: AuthMethod;
AccountSchema?: CoValueClass<Acc> & {
fromNode: (typeof Account)["fromNode"];
};
guest: false;
} & BaseBrowserContextOptions;
export type BrowserGuestContextOptions = {
guest: true;
} & BaseBrowserContextOptions;
export type BaseBrowserContextOptions = {
@@ -52,34 +61,25 @@ export type BaseBrowserContextOptions = {
reconnectionTimeout?: number;
storage?: StorageConfig;
crypto?: CryptoProvider;
localOnly?: boolean;
};
function getAnonymousUserAuth() {
const auth = new BrowserAnonymousAuth("Anonymous user", {
onSignedIn: () => {},
onError: () => {},
});
return auth;
}
/** @category Context Creation */
export async function createJazzBrowserContext<Acc extends Account>(
options: BrowserContextOptions<Acc>,
): Promise<BrowserContext<Acc>>;
export async function createJazzBrowserContext(
options: BaseBrowserContextOptions,
): Promise<BrowserGuestContext>;
export async function createJazzBrowserContext<Acc extends Account>(
options: BrowserContextOptions<Acc> | BaseBrowserContextOptions,
): Promise<BrowserContext<Acc> | BrowserGuestContext>;
export async function createJazzBrowserContext<Acc extends Account>(
options: BrowserContextOptions<Acc> | BaseBrowserContextOptions,
options: BrowserContextOptions<Acc> | BrowserGuestContextOptions,
): Promise<BrowserContext<Acc> | BrowserGuestContext> {
const crypto = options.crypto || (await WasmCrypto.create());
let node: LocalNode | undefined = undefined;
const wsPeer = createWebSocketPeerWithReconnection(
options.peer,
options.reconnectionTimeout,
(peer) => {
if (node) {
node.syncManager.addPeer(peer);
}
},
);
const { useSingleTabOPFS, useIndexedDB } = getStorageOptions(options.storage);
const peersToLoadFrom: Peer[] = [];
@@ -97,13 +97,37 @@ export async function createJazzBrowserContext<Acc extends Account>(
peersToLoadFrom.push(await IDBStorage.asPeer());
}
peersToLoadFrom.push(wsPeer.peer);
const wsPeer = createWebSocketPeerWithReconnection(
options.peer,
options.reconnectionTimeout,
(peer) => {
if (node) {
node.syncManager.addPeer(peer);
} else {
peersToLoadFrom.push(peer);
}
},
(peer) => {
peersToLoadFrom.splice(peersToLoadFrom.indexOf(peer), 1);
},
);
function toggleNetwork(enabled: boolean) {
if (enabled) {
wsPeer.enable();
} else {
wsPeer.disable();
}
}
toggleNetwork(!options.localOnly);
const context =
"auth" in options
options.guest !== true
? await createJazzContext({
AccountSchema: options.AccountSchema,
auth: options.auth,
AccountSchema:
"AccountSchema" in options ? options.AccountSchema : undefined,
auth: options.auth ?? getAnonymousUserAuth(),
crypto,
peersToLoadFrom,
sessionProvider: provideBrowserLockSession,
@@ -119,8 +143,9 @@ export async function createJazzBrowserContext<Acc extends Account>(
return "account" in context
? {
me: context.account,
toggleNetwork,
done: () => {
wsPeer.done();
wsPeer.disable();
context.done();
},
logOut: () => {
@@ -129,8 +154,9 @@ export async function createJazzBrowserContext<Acc extends Account>(
}
: {
guest: context.agent,
toggleNetwork,
done: () => {
wsPeer.done();
wsPeer.disable();
context.done();
},
logOut: () => {
@@ -170,9 +196,9 @@ export function provideBrowserLockSession(
if (!lock) return "noLock";
const sessionID =
localStorage[accountID + "_" + idx] ||
localStorage.getItem(accountID + "_" + idx) ||
crypto.newRandomSessionID(accountID as RawAccountID | AgentID);
localStorage[accountID + "_" + idx] = sessionID;
localStorage.setItem(accountID + "_" + idx, sessionID);
// console.debug(
// "Got lock",
@@ -180,7 +206,7 @@ export function provideBrowserLockSession(
// sessionID
// );
resolveSession(sessionID);
resolveSession(sessionID as SessionID);
await donePromise;
console.log("Done with lock", accountID + "_" + idx, sessionID);

View File

@@ -0,0 +1,140 @@
// @vitest-environment happy-dom
import { AgentSecret } from "cojson";
import { Account } from "jazz-tools";
import { ID } from "jazz-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BrowserAnonymousAuth } from "../auth/AnonymousAuth";
import { AuthSecretStorage } from "../auth/AuthSecretStorage";
beforeEach(() => {
AuthSecretStorage.clear();
});
describe("BrowserAnonymousAuth", () => {
function createDriver() {
return {
onSignedIn: vi.fn(),
onError: vi.fn(),
} satisfies BrowserAnonymousAuth.Driver;
}
describe("initialization", () => {
it("should initialize with default username", () => {
const driver = createDriver();
const auth = new BrowserAnonymousAuth("Anonymous User", driver);
expect(auth.defaultUserName).toBe("Anonymous User");
});
});
describe("authentication flows", () => {
it("should handle new user signup", async () => {
const driver = createDriver();
const auth = new BrowserAnonymousAuth("Anonymous User", driver);
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "mock-secret" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
await result.saveCredentials?.({
accountID: "test123" as ID<Account>,
secret: "mock-secret" as AgentSecret,
});
result.onSuccess();
expect(result.type).toBe("new");
expect(result.creationProps).toEqual({
name: "Anonymous User",
anonymous: true,
});
expect(driver.onSignedIn).toHaveBeenCalled();
expect(AuthSecretStorage.get()).toEqual({
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1, 2, 3]),
provider: "anonymous",
accountSecret: "mock-secret" as AgentSecret,
});
});
it("should handle existing user login", async () => {
const driver = createDriver();
const auth = new BrowserAnonymousAuth("Anonymous User", driver);
// Set up existing user in storage
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1, 2, 3]),
accountSecret: "mock-secret" as AgentSecret,
provider: "anonymous",
});
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "mock-secret" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
result.onSuccess();
expect(result.type).toBe("existing");
expect(result.credentials).toEqual({
accountID: "test123",
secret: "mock-secret",
});
expect(driver.onSignedIn).toHaveBeenCalled();
});
it("should handle errors during login", async () => {
const driver = createDriver();
const auth = new BrowserAnonymousAuth("Anonymous User", driver);
// Set up existing user in storage
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1, 2, 3]),
accountSecret: "mock-secret" as AgentSecret,
provider: "anonymous",
});
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "mock-secret" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
const testError = new Error("Test error");
result.onError(testError);
expect(driver.onError).toHaveBeenCalledWith(testError);
});
it("should clear storage on logout", async () => {
const driver = createDriver();
const auth = new BrowserAnonymousAuth("Anonymous User", driver);
// Set up existing user in storage
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1, 2, 3]),
accountSecret: "mock-secret" as AgentSecret,
provider: "anonymous",
});
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "mock-secret" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
result.logOut();
expect(AuthSecretStorage.get()).toBeNull();
});
});
});

View File

@@ -0,0 +1,219 @@
// @vitest-environment happy-dom
import { Account } from "jazz-tools";
import { ID } from "jazz-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AuthSecretStorage } from "../auth/AuthSecretStorage";
describe("AuthSecretStorage", () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
});
describe("migrate", () => {
it("should migrate demo auth secret", () => {
const demoSecret = JSON.stringify({
accountID: "demo123",
accountSecret: "secret123",
});
localStorage.setItem("demo-auth-logged-in-secret", demoSecret);
AuthSecretStorage.migrate();
expect(localStorage.getItem("jazz-logged-in-secret")).toBe(demoSecret);
expect(localStorage.getItem("demo-auth-logged-in-secret")).toBeNull();
});
it("should migrate clerk auth secret", () => {
const clerkSecret = JSON.stringify({
accountID: "clerk123",
accountSecret: "secret123",
});
localStorage.setItem("jazz-clerk-auth", clerkSecret);
AuthSecretStorage.migrate();
expect(localStorage.getItem("jazz-logged-in-secret")).toBe(clerkSecret);
expect(localStorage.getItem("jazz-clerk-auth")).toBeNull();
});
});
describe("get", () => {
it("should return null when no data exists", () => {
expect(AuthSecretStorage.get()).toBeNull();
});
it("should return credentials with secretSeed", () => {
const credentials = {
accountID: "test123",
secretSeed: [1, 2, 3],
accountSecret: "secret123",
provider: "anonymous",
};
localStorage.setItem(
"jazz-logged-in-secret",
JSON.stringify(credentials),
);
const result = AuthSecretStorage.get();
expect(result).toEqual({
accountID: "test123",
secretSeed: new Uint8Array([1, 2, 3]),
accountSecret: "secret123",
provider: "anonymous",
});
});
it("should return non-anonymous credentials without secretSeed", () => {
const credentials = {
accountID: "test123",
accountSecret: "secret123",
provider: "passphrase",
};
localStorage.setItem(
"jazz-logged-in-secret",
JSON.stringify(credentials),
);
const result = AuthSecretStorage.get();
expect(result).toEqual({
accountID: "test123",
accountSecret: "secret123",
provider: "passphrase",
});
});
it("should throw error for invalid data", () => {
localStorage.setItem(
"jazz-logged-in-secret",
JSON.stringify({ invalid: "data" }),
);
expect(() => AuthSecretStorage.get()).toThrow(
"Invalid auth secret storage data",
);
});
});
describe("set", () => {
it("should set credentials with secretSeed", () => {
const payload = {
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1, 2, 3]),
accountSecret:
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
provider: "passphrase",
};
AuthSecretStorage.set(payload);
const stored = JSON.parse(localStorage.getItem("jazz-logged-in-secret")!);
expect(stored).toEqual({
accountID: "test123",
secretSeed: [1, 2, 3],
accountSecret: "secret123",
provider: "passphrase",
});
});
it("should set credentials without secretSeed", () => {
const payload = {
accountID: "test123" as ID<Account>,
accountSecret:
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
provider: "passphrase",
};
AuthSecretStorage.set(payload);
const stored = JSON.parse(localStorage.getItem("jazz-logged-in-secret")!);
expect(stored).toEqual(payload);
});
it("should emit update event when setting credentials", () => {
const handler = vi.fn();
AuthSecretStorage.onUpdate(handler);
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
accountSecret:
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
provider: "passphrase",
});
expect(handler).toHaveBeenCalled();
});
});
describe("isAnonymous", () => {
it("should return false when no data exists", () => {
expect(AuthSecretStorage.isAnonymous()).toBe(false);
});
it("should return true for anonymous credentials", () => {
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
accountSecret:
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
secretSeed: new Uint8Array([1, 2, 3]),
provider: "anonymous",
});
expect(AuthSecretStorage.isAnonymous()).toBe(true);
});
it("should return false for non-anonymous credentials", () => {
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
accountSecret:
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
secretSeed: new Uint8Array([1, 2, 3]),
provider: "demo",
});
expect(AuthSecretStorage.isAnonymous()).toBe(false);
});
});
describe("onUpdate", () => {
it("should add and remove event listener", () => {
const handler = vi.fn();
const removeListener = AuthSecretStorage.onUpdate(handler);
AuthSecretStorage.emitUpdate();
expect(handler).toHaveBeenCalledTimes(1);
handler.mockClear();
removeListener();
AuthSecretStorage.emitUpdate();
expect(handler).not.toHaveBeenCalled();
});
});
describe("clear", () => {
it("should remove stored credentials", () => {
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
accountSecret:
"secret123" as `sealerSecret_z${string}/signerSecret_z${string}`,
provider: "passphrase",
});
AuthSecretStorage.clear();
expect(AuthSecretStorage.get()).toBeNull();
});
it("should emit update event when clearing", () => {
const handler = vi.fn();
AuthSecretStorage.onUpdate(handler);
AuthSecretStorage.clear();
expect(handler).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,318 @@
// @vitest-environment happy-dom
import { AgentSecret } from "cojson";
import { Account } from "jazz-tools";
import { ID } from "jazz-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AuthSecretStorage } from "../auth/AuthSecretStorage";
import { BrowserPasskeyAuth } from "../auth/PasskeyAuth";
import { waitFor } from "./utils";
beforeEach(() => {
AuthSecretStorage.clear();
});
describe("BrowserPasskeyAuth", () => {
let mockNavigator: any;
function createDriver() {
return {
onReady: vi.fn().mockImplementation((api) => {
api.signUp("testuser");
}),
onSignedIn: vi.fn(),
onError: vi.fn(),
} satisfies BrowserPasskeyAuth.Driver;
}
beforeEach(() => {
mockNavigator = {
credentials: {
create: vi.fn(),
get: vi.fn(),
},
};
global.navigator = mockNavigator;
});
describe("initialization", () => {
it("should initialize with default hostname", () => {
const driver = createDriver();
const auth = new BrowserPasskeyAuth(driver, "Test App");
expect(auth.appName).toBe("Test App");
expect(auth.appHostname).toBe(window.location.hostname);
});
it("should initialize with custom hostname", () => {
const driver = createDriver();
const auth = new BrowserPasskeyAuth(driver, "Test App", "custom.host");
expect(auth.appHostname).toBe("custom.host");
});
it("should handle existing user login", async () => {
const driver = createDriver();
const auth = new BrowserPasskeyAuth(driver, "Test App");
driver.onReady = vi.fn().mockRejectedValue(new Error("test"));
// Set up existing user in storage
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1]),
accountSecret: "fromAuthStorage" as AgentSecret,
provider: "passkey",
});
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([2]),
agentSecretFromSecretSeed: () => "xxxxx" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
result.onSuccess();
expect(result.type).toBe("existing");
if (result.type !== "existing") {
throw new Error("Expected existing user login");
}
expect(result.credentials).toEqual({
accountID: "test123",
secret: "fromAuthStorage",
});
expect(driver.onSignedIn).toHaveBeenCalled();
expect(driver.onReady).not.toHaveBeenCalled();
});
it("should not automatically login when the existing user is anonymous", async () => {
const driver = createDriver();
const auth = new BrowserPasskeyAuth(driver, "Test App");
driver.onReady = vi.fn().mockImplementation((api) => {
api.logIn();
});
mockNavigator.credentials.get.mockResolvedValue({
response: {
userHandle: new ArrayBuffer(32), // Mocked credential payload
},
});
// Set up existing user in storage
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1]),
accountSecret: "fromAuthStorage" as AgentSecret,
provider: "anonymous",
});
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([1]),
agentSecretFromSecretSeed: () => "fromLogIn" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
if (result.type !== "existing") {
throw new Error("Expected existing user login");
}
expect(result.credentials).toEqual({
accountID: "co_z",
secret: "fromLogIn",
});
});
it("should upgrade anonymous account to passkey", async () => {
const driver = createDriver();
const auth = new BrowserPasskeyAuth(driver, "Test App");
driver.onReady = vi.fn().mockImplementation((api) => {
api.signUp("testuser");
});
mockNavigator.credentials.create.mockResolvedValue({
type: "public-key",
id: new Uint8Array([1, 2, 3, 4]),
});
// Set up existing user in storage
AuthSecretStorage.set({
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1]),
accountSecret: "fromAuthStorage" as AgentSecret,
provider: "anonymous",
});
const mockCrypto = {
newRandomSecretSeed: () => new Uint8Array([1]),
agentSecretFromSecretSeed: () => "fromLogIn" as AgentSecret,
};
const result = await auth.start(mockCrypto as any);
if (result.type !== "existing") {
throw new Error("Expected existing user login");
}
await result.saveCredentials?.({
accountID: "test123" as ID<Account>,
secret: "fromLogIn" as AgentSecret,
});
expect(mockNavigator.credentials.create).toHaveBeenCalled();
expect(result.credentials).toEqual({
accountID: "test123" as ID<Account>,
secret: "fromAuthStorage" as AgentSecret,
});
});
});
describe("authentication flows", () => {
it("should handle new user signup", async () => {
const driver = createDriver();
driver.onReady = vi.fn().mockImplementation((api) => {
api.signUp("testuser");
});
const auth = new BrowserPasskeyAuth(driver, "Test App");
mockNavigator.credentials.create.mockResolvedValue({
type: "public-key",
id: new Uint8Array([1, 2, 3, 4]),
});
const result = await auth.start({
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "mock-secret" as AgentSecret,
} as any);
await result.saveCredentials?.({
accountID: "test123" as ID<Account>,
secret: "mock-secret" as AgentSecret,
});
result.onSuccess();
expect(result.type).toBe("new");
expect(driver.onSignedIn).toHaveBeenCalled();
expect(AuthSecretStorage.get()).toEqual({
accountID: "test123" as ID<Account>,
secretSeed: new Uint8Array([1, 2, 3]),
provider: "passkey",
accountSecret: "mock-secret" as AgentSecret,
});
});
it("should handle existing user login", async () => {
const driver = createDriver();
const auth = new BrowserPasskeyAuth(driver, "Test App");
driver.onReady = vi.fn().mockImplementation((api) => {
api.logIn();
});
mockNavigator.credentials.get.mockResolvedValue({
response: {
userHandle: new ArrayBuffer(32), // Mocked credential payload
},
});
const result = await auth.start({
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "mock-secret" as AgentSecret,
} as any);
result.saveCredentials?.({
accountID: "test123" as ID<Account>,
secret: "mock-secret" as AgentSecret,
});
result.onSuccess();
expect(result.type).toBe("existing");
expect(driver.onSignedIn).toHaveBeenCalled();
expect(AuthSecretStorage.get()).toEqual({
accountID: "test123" as ID<Account>,
secretSeed: expect.any(Uint8Array),
provider: "passkey",
accountSecret: "mock-secret" as AgentSecret,
});
});
it("should handle passkey errors during login (invalid credentials)", async () => {
const driver = createDriver();
const auth = new BrowserPasskeyAuth(driver, "Test App");
driver.onReady = vi.fn().mockImplementation((api) => {
api.logIn();
});
mockNavigator.credentials.get.mockResolvedValue(null);
auth.start({
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "mock-secret" as AgentSecret,
} as any);
await waitFor(() => {
expect(driver.onError).toHaveBeenCalledWith(
"Error while accessing the passkey credentials",
);
});
});
it("should handle passkey errors during login (rejected by user)", async () => {
const driver = createDriver();
const auth = new BrowserPasskeyAuth(driver, "Test App");
driver.onReady = vi.fn().mockImplementation((api) => {
api.logIn();
});
mockNavigator.credentials.get.mockRejectedValue(
new Error("User rejected the passkey"),
);
auth.start({
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "mock-secret" as AgentSecret,
} as any);
await waitFor(() => {
expect(driver.onError).toHaveBeenCalledWith(
"Error while accessing the passkey credentials",
);
});
});
it("should handle passkey during signup", async () => {
const driver = createDriver();
const auth = new BrowserPasskeyAuth(driver, "Test App");
driver.onReady = vi.fn().mockImplementation((api) => {
api.signUp("testuser");
});
mockNavigator.credentials.create.mockRejectedValue(
new Error("User rejected the passkey"),
);
const result = await auth.start({
newRandomSecretSeed: () => new Uint8Array([1, 2, 3]),
agentSecretFromSecretSeed: () => "mock-secret" as AgentSecret,
} as any);
const saveResult = result.saveCredentials?.({
accountID: "test123" as ID<Account>,
secret: "mock-secret" as AgentSecret,
});
await expect(saveResult).rejects.toThrow("User rejected the passkey");
});
});
});

View File

@@ -0,0 +1,174 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AuthSecretStorage,
BrowserAnonymousAuth,
createJazzBrowserContext,
} from "../index";
import "fake-indexeddb/auto";
import { cojsonInternals } from "cojson";
import { TestJSCrypto, createJazzTestAccount } from "jazz-tools/testing";
import { createWebSocketPeerWithReconnection } from "../createWebSocketPeerWithReconnection";
const crypto = await TestJSCrypto.create();
const account = await createJazzTestAccount();
const syncServer = account._raw.core.node;
// Mock navigator.locks API
Object.defineProperty(navigator, "locks", {
value: {
request: vi.fn().mockImplementation(async (name, options, callback) => {
// Simulate lock acquisition and callback execution
const lock = { name };
return callback(lock);
}),
},
configurable: true,
});
vi.mock("../createWebSocketPeerWithReconnection", () => ({
createWebSocketPeerWithReconnection: vi.fn(),
}));
describe("createJazzBrowserContext", () => {
let mockWebSocketPeer: any;
beforeEach(async () => {
// Reset mocks and IndexedDB before each test
vi.clearAllMocks();
AuthSecretStorage.clear();
// Clear all IndexedDB databases
const databases = await indexedDB.databases();
await Promise.all(
databases.map(({ name }) =>
name ? indexedDB.deleteDatabase(name) : Promise.resolve(),
),
);
const [aPeer, bPeer] = cojsonInternals.connectedPeers("a", "b", {
peer1role: "client",
peer2role: "server",
});
syncServer.syncManager.addPeer(aPeer);
// Setup mock for WebSocket peer
mockWebSocketPeer = {
enable: vi.fn(),
disable: vi.fn(),
};
vi.mocked(createWebSocketPeerWithReconnection).mockImplementation(
(peer, reconnectionTimeout, addPeer, removePeer) => {
mockWebSocketPeer.enable.mockImplementation(() => {
addPeer(bPeer);
});
mockWebSocketPeer.disable.mockImplementation(() => {
removePeer(bPeer);
});
return mockWebSocketPeer;
},
);
});
describe("toggleNetwork", () => {
it("should initialize with network enabled by default", async () => {
const context = await createJazzBrowserContext({
peer: "wss://test.com",
storage: "indexedDB",
guest: false,
crypto,
});
expect(mockWebSocketPeer.enable).toHaveBeenCalledTimes(1);
context.done();
});
it("should initialize with network disabled when localOnly is true", async () => {
const context = await createJazzBrowserContext({
peer: "wss://test.com",
storage: "indexedDB",
guest: false,
localOnly: true,
crypto,
});
expect(mockWebSocketPeer.enable).not.toHaveBeenCalled();
context.done();
});
it("should enable network when toggled on", async () => {
const context = await createJazzBrowserContext({
peer: "wss://test.com",
storage: "indexedDB",
guest: false,
localOnly: true,
crypto,
});
context.toggleNetwork(true);
expect(mockWebSocketPeer.enable).toHaveBeenCalledTimes(1);
});
it("should disable network when toggled off", async () => {
const context = await createJazzBrowserContext({
peer: "wss://test.com",
storage: "indexedDB",
guest: false,
crypto,
});
context.toggleNetwork(false);
expect(mockWebSocketPeer.enable).toHaveBeenCalledTimes(1);
expect(mockWebSocketPeer.disable).toHaveBeenCalledTimes(1);
});
it("should sync with the server when network is enabled", async () => {
const context = await createJazzBrowserContext({
peer: "wss://test.com",
storage: "indexedDB",
guest: false,
localOnly: true,
crypto,
});
if (!("me" in context)) {
throw new Error("me not found");
}
const map = context.me._raw.createMap();
context.toggleNetwork(true);
await context.me.waitForAllCoValuesSync();
const value = await syncServer.load(map.id);
expect(value).not.toBe("unavailable");
});
it("should run the provided auth method", async () => {
const driver = {
onSignedIn: vi.fn(),
onError: vi.fn(),
} satisfies BrowserAnonymousAuth.Driver;
const auth = new BrowserAnonymousAuth("Anonymous User", driver);
await createJazzBrowserContext({
peer: "wss://test.com",
storage: "indexedDB",
guest: false,
crypto,
auth,
});
expect(driver.onSignedIn).toHaveBeenCalled();
});
});
});

View File

@@ -41,12 +41,15 @@ describe("createWebSocketPeerWithReconnection", () => {
test("should reset reconnection timeout when coming online", async () => {
vi.useFakeTimers();
const addPeerMock = vi.fn();
const removePeerMock = vi.fn();
const { done } = createWebSocketPeerWithReconnection(
const connection = createWebSocketPeerWithReconnection(
"ws://localhost:8080",
500,
addPeerMock,
removePeerMock,
);
connection.enable();
// Simulate multiple disconnections to increase timeout
const initialPeer = vi.mocked(createWebSocketPeer).mock.results[0]!.value;
@@ -54,12 +57,12 @@ describe("createWebSocketPeerWithReconnection", () => {
await vi.advanceTimersByTimeAsync(1000);
expect(addPeerMock).toHaveBeenCalledTimes(1);
expect(addPeerMock).toHaveBeenCalledTimes(2);
vi.mocked(createWebSocketPeer).mock.results[1]!.value.onClose();
await vi.advanceTimersByTimeAsync(2000);
expect(addPeerMock).toHaveBeenCalledTimes(2);
expect(addPeerMock).toHaveBeenCalledTimes(3);
// Resets the timeout to initial value
window.dispatchEvent(new Event("online"));
@@ -68,20 +71,23 @@ describe("createWebSocketPeerWithReconnection", () => {
vi.mocked(createWebSocketPeer).mock.results[2]!.value.onClose();
await vi.advanceTimersByTimeAsync(1000);
expect(addPeerMock).toHaveBeenCalledTimes(3);
expect(addPeerMock).toHaveBeenCalledTimes(4);
done();
connection.disable();
});
test("should wait for online event or timeout before reconnecting", async () => {
vi.useFakeTimers();
const addPeerMock = vi.fn();
const { done } = createWebSocketPeerWithReconnection(
const removePeerMock = vi.fn();
const connection = createWebSocketPeerWithReconnection(
"ws://localhost:8080",
500,
addPeerMock,
removePeerMock,
);
connection.enable();
const initialPeer = vi.mocked(createWebSocketPeer).mock.results[0]!.value;
@@ -103,18 +109,20 @@ describe("createWebSocketPeerWithReconnection", () => {
// Should reconnect immediately after coming online
expect(createWebSocketPeer).toHaveBeenCalledTimes(2);
done();
connection.disable();
});
test("should clean up event listeners when done", () => {
test("should clean up event listeners when disabled", () => {
const addPeerMock = vi.fn();
const { done } = createWebSocketPeerWithReconnection(
const removePeerMock = vi.fn();
const connection = createWebSocketPeerWithReconnection(
"ws://localhost:8080",
1000,
addPeerMock,
removePeerMock,
);
done();
connection.enable();
connection.disable();
expect(window.removeEventListener).toHaveBeenCalledWith(
"online",
@@ -122,19 +130,22 @@ describe("createWebSocketPeerWithReconnection", () => {
);
});
test("should not attempt reconnection after done is called", async () => {
test("should not attempt reconnection after disable is called", async () => {
vi.useFakeTimers();
const addPeerMock = vi.fn();
const { done } = createWebSocketPeerWithReconnection(
const removePeerMock = vi.fn();
const connection = createWebSocketPeerWithReconnection(
"ws://localhost:8080",
500,
addPeerMock,
removePeerMock,
);
connection.enable();
const initialPeer = vi.mocked(createWebSocketPeer).mock.results[0]!.value;
done();
connection.disable();
initialPeer.onClose();
await vi.advanceTimersByTimeAsync(1000);

View File

@@ -1,8 +1,9 @@
import { CoID, LocalNode, RawCoValue } from "cojson";
import { connectedPeers } from "cojson/src/streamUtils.js";
import { Account, WasmCrypto } from "jazz-tools";
import { Account } from "jazz-tools";
import { TestJSCrypto } from "jazz-tools/testing";
const Crypto = await WasmCrypto.create();
const Crypto = await TestJSCrypto.create();
export async function setupTwoNodes() {
const [serverAsPeer, clientAsPeer] = connectedPeers(

View File

@@ -1,13 +1,14 @@
import { AgentSecret } from "cojson";
import { Account } from "jazz-tools/src/coValues/account.js";
import { ID } from "jazz-tools/src/coValues/interfaces.js";
import { AuthSecretStorage } from "../auth/AuthSecretStorage.js";
function exportAccountToInspector(localStorageKey = "jazz-logged-in-secret") {
const localStorageData = JSON.parse(localStorage[localStorageKey]) as {
accountID: ID<Account>;
accountSecret: AgentSecret;
};
const encodedAccountSecret = btoa(localStorageData?.accountSecret);
function exportAccountToInspector() {
const localStorageData = AuthSecretStorage.get();
if (!localStorageData) {
console.error("No account data found in localStorage");
return;
}
const encodedAccountSecret = btoa(localStorageData.accountSecret);
window.open(
new URL(
`#/import/${localStorageData?.accountID}/${encodedAccountSecret}`,
@@ -17,7 +18,7 @@ function exportAccountToInspector(localStorageKey = "jazz-logged-in-secret") {
);
}
function listenForCmdJ(localStorageKey?: string) {
function listenForCmdJ() {
if (typeof window === "undefined") return;
const cb = (e: any) => {
@@ -27,7 +28,7 @@ function listenForCmdJ(localStorageKey?: string) {
"Are you sure you want to inspect your account using inspector.jazz.tools? This lets anyone with the secret inspector URL read your data and impersonate you.",
)
) {
exportAccountToInspector(localStorageKey);
exportAccountToInspector();
}
}
};

View File

@@ -2,12 +2,18 @@ import {
BrowserClerkAuth,
type MinimalClerkClient,
} from "jazz-browser-auth-clerk";
import { useInJazzAuth } from "jazz-react";
import { useMemo, useState } from "react";
export function useJazzClerkAuth(
clerk: MinimalClerkClient & {
signOut: () => Promise<unknown>;
},
onAnonymousUserUpgrade: (props: {
username: string;
isSignUp: boolean;
isLogIn: boolean;
}) => void = () => {},
) {
const [state, setState] = useState<{ errors: string[] }>({ errors: [] });
@@ -26,5 +32,10 @@ export function useJazzClerkAuth(
);
}, [clerk.user]);
useInJazzAuth({
auth: authMethod,
onAuthChange: onAnonymousUserUpgrade,
});
return [authMethod, state] as const;
}

View File

@@ -5,12 +5,16 @@ import { Account, AccountClass, AnonymousJazzAgent } from "jazz-tools";
/** @category Context Creation */
export type JazzAuthContext<Acc extends Account> = {
me: Acc;
toggleNetwork?: (enabled: boolean) => void;
refreshContext?: () => void;
logOut: () => void;
done: () => void;
};
export type JazzGuestContext = {
guest: AnonymousJazzAgent;
toggleNetwork?: (enabled: boolean) => void;
refreshContext?: () => void;
logOut: () => void;
done: () => void;
};

View File

@@ -11,7 +11,10 @@ export function JazzTestProvider<Acc extends Account>({
account: Acc | { guest: AnonymousJazzAgent };
}) {
const value = useMemo(() => {
return getJazzContextShape(account);
return {
...getJazzContextShape(account),
toggleNetwork: () => {},
};
}, [account]);
return <JazzContext.Provider value={value}>{children}</JazzContext.Provider>;

View File

@@ -1,7 +1,7 @@
import { BrowserOnboardingAuth } from "jazz-browser";
import { useMemo, useState } from "react";
import { AuthSecretStorage, BrowserAnonymousAuth } from "jazz-browser";
import { useEffect, useMemo, useState } from "react";
type OnboardingAuthState = (
type AnonymousAuthState = (
| {
state: "uninitialized";
}
@@ -38,20 +38,20 @@ export function useOnboardingAuth(
defaultUserName: "Anonymous user",
},
) {
const [state, setState] = useState<OnboardingAuthState>({
const [state, setState] = useState<AnonymousAuthState>({
state: "loading",
errors: [],
});
const authMethod = useMemo(() => {
return new BrowserOnboardingAuth(defaultUserName, {
return new BrowserAnonymousAuth(defaultUserName, {
onSignedIn: ({ logOut }) => {
setState({ state: "signedIn", logOut, errors: [] });
},
onError: (error) => {
setState((current) => ({
...current,
errors: [...current.errors, error.toString()],
errors: [error.toString()],
}));
},
});
@@ -59,3 +59,19 @@ export function useOnboardingAuth(
return [authMethod, state] as const;
}
export function useIsAnonymousUser() {
const [isAnonymous, setIsAnonymous] = useState(() =>
AuthSecretStorage.isAnonymous(),
);
useEffect(() => {
function handleUpdate() {
setIsAnonymous(AuthSecretStorage.isAnonymous());
}
return AuthSecretStorage.onUpdate(handleUpdate);
}, []);
return isAnonymous;
}

View File

@@ -1,7 +1,8 @@
import { AgentSecret } from "cojson";
import { BrowserDemoAuth } from "jazz-browser";
import { JazzContext } from "jazz-react-core";
import { Account, ID } from "jazz-tools";
import { useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
type DemoAuthState = (
| {
@@ -64,7 +65,7 @@ export function useDemoAuth({
onError: (error) => {
setState((current) => ({
...current,
errors: [...current.errors, error.toString()],
errors: [error.toString()],
}));
},
},
@@ -72,6 +73,14 @@ export function useDemoAuth({
);
}, [seedAccounts]);
const context = useContext(JazzContext);
if (context) {
throw new Error(
"DemoAuth can't be used inside a JazzContext or to upgrade anonymous users.",
);
}
return [authMethod, state] as const;
}

View File

@@ -1,5 +1,6 @@
import { BrowserPasskeyAuth } from "jazz-browser";
import { useMemo, useState } from "react";
import { AuthChangeProps, useInJazzAuth } from "./useInJazzAuth.js";
export type PasskeyAuthState = (
| { state: "uninitialized" }
@@ -27,9 +28,11 @@ export type PasskeyAuthState = (
export function usePasskeyAuth({
appName,
appHostname,
onAnonymousUserUpgrade,
}: {
appName: string;
appHostname?: string;
onAnonymousUserUpgrade?: (props: AuthChangeProps) => void;
}) {
const [state, setState] = useState<PasskeyAuthState>({
state: "loading",
@@ -40,12 +43,12 @@ export function usePasskeyAuth({
return new BrowserPasskeyAuth(
{
onReady(next) {
setState({
setState((state) => ({
state: "ready",
logIn: next.logIn,
signUp: next.signUp,
errors: [],
});
errors: state.errors,
}));
},
onSignedIn(next) {
setState({
@@ -60,7 +63,7 @@ export function usePasskeyAuth({
onError(error) {
setState((state) => ({
...state,
errors: [...state.errors, error.toString()],
errors: [error.toString()],
}));
},
},
@@ -69,6 +72,13 @@ export function usePasskeyAuth({
);
}, [appName, appHostname]);
useInJazzAuth({
auth: authMethod,
onAuthChange: (props) => {
onAnonymousUserUpgrade?.(props);
},
});
return [authMethod, state] as const;
}

View File

@@ -1,7 +1,8 @@
import { generateMnemonic } from "@scure/bip39";
import { cojsonInternals } from "cojson";
import { BrowserPassphraseAuth } from "jazz-browser";
import { useMemo, useState } from "react";
import { JazzContext } from "jazz-react-core";
import { useContext, useMemo, useState } from "react";
export type PassphraseAuthState = (
| { state: "uninitialized" }
@@ -28,12 +29,8 @@ export type PassphraseAuthState = (
* @category Auth Providers
*/
export function usePassphraseAuth({
appName,
appHostname,
wordlist,
}: {
appName: string;
appHostname?: string;
wordlist: string[];
}) {
const [state, setState] = useState<PassphraseAuthState>({
@@ -70,15 +67,21 @@ export function usePassphraseAuth({
onError(error) {
setState((state) => ({
...state,
errors: [...state.errors, error.toString()],
errors: [error.toString()],
}));
},
},
wordlist,
appName,
appHostname,
);
}, [appName, appHostname, wordlist]);
}, [wordlist]);
const context = useContext(JazzContext);
if (context) {
throw new Error(
"PassphraseAuth can't be used inside a JazzContext or to upgrade anonymous users.",
);
}
return [authMethod, state] as const;
}

View File

@@ -1,4 +1,16 @@
export { useOnboardingAuth } from "./OnboardingAuth.js";
export {
useOnboardingAuth,
useIsAnonymousUser,
} from "./AnonymousAuth.js";
export { useDemoAuth, DemoAuthBasicUI } from "./DemoAuth.js";
export { usePasskeyAuth, PasskeyAuthBasicUI } from "./PasskeyAuth.js";
export { usePassphraseAuth, PassphraseAuthBasicUI } from "./PassphraseAuth.js";
export {
usePasskeyAuth,
PasskeyAuthBasicUI,
type PasskeyAuthState,
} from "./PasskeyAuth.js";
export {
usePassphraseAuth,
PassphraseAuthBasicUI,
type PassphraseAuthState,
} from "./PassphraseAuth.js";
export { useInJazzAuth } from "./useInJazzAuth.js";

View File

@@ -0,0 +1,61 @@
import { JazzContext } from "jazz-react-core";
import { AuthMethod } from "jazz-tools";
import { useContext, useEffect } from "react";
import { useAccount } from "../hooks.js";
export type AuthChangeProps = {
username: string;
isSignUp: boolean;
isLogIn: boolean;
};
export function useInJazzAuth({
auth,
onAuthChange,
}: {
auth: AuthMethod;
onAuthChange: (props: AuthChangeProps) => void;
}) {
const { me } = useAccount();
const context = useContext(JazzContext);
useEffect(() => {
if (!context) return;
const runAuth = async () => {
const result = await auth.start(me._raw.core.node.crypto);
if (result.type === "new") {
throw new Error(
"New credentials generation is not supported in this context, yet",
);
}
try {
await result.saveCredentials?.({
accountID: result.credentials.accountID,
secret: result.credentials.secret,
});
} catch (error) {
result.onError(error as string | Error);
return;
}
result.onSuccess();
const isSignUp = result.credentials.accountID === me.id;
if (!isSignUp) {
context.refreshContext?.();
}
onAuthChange({
username: result.username ?? "",
isSignUp,
isLogIn: !isSignUp,
});
};
runAuth();
}, [auth, me]);
}

View File

@@ -1,11 +1,14 @@
import {
BaseBrowserContextOptions,
BrowserContext,
BrowserGuestContext,
createJazzBrowserContext,
} from "jazz-browser";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect } from "react";
import { JazzContext, JazzContextType } from "jazz-react-core";
import { Account, AccountClass, AuthMethod } from "jazz-tools";
import { useIsAnonymousUser } from "./auth/AnonymousAuth.js";
export interface Register {}
@@ -13,14 +16,141 @@ export type RegisteredAccount = Register extends { Account: infer Acc }
? Acc
: Account;
export type JazzProviderProps<Acc extends Account = RegisteredAccount> = {
children: React.ReactNode;
auth: AuthMethod | "guest";
export type JazzContextManagerProps<Acc extends Account = RegisteredAccount> = {
auth?: AuthMethod | "guest";
peer: `wss://${string}` | `ws://${string}`;
localOnly?: boolean;
storage?: BaseBrowserContextOptions["storage"];
AccountSchema?: AccountClass<Acc>;
};
export type JazzProviderProps<Acc extends Account = RegisteredAccount> = {
children: React.ReactNode;
localOnly?: boolean | "anonymous";
} & Omit<JazzContextManagerProps<Acc>, "localOnly">;
class JazzContextManager<Acc extends Account = RegisteredAccount> {
private value: JazzContextType<Acc> | undefined;
private context: BrowserGuestContext | BrowserContext<Acc> | undefined;
private props: JazzContextManagerProps<Acc> | undefined;
lastCallId: number = 0;
async createContext(props: JazzContextManagerProps<Acc>) {
const callId = ++this.lastCallId; // To avoid race conditions
this.props = { ...props };
const currentContext = await createJazzBrowserContext<Acc>(
props.auth === "guest"
? {
guest: true,
peer: props.peer,
storage: props.storage,
localOnly: props.localOnly,
}
: {
guest: false,
AccountSchema: props.AccountSchema,
auth: props.auth,
peer: props.peer,
storage: props.storage,
localOnly: props.localOnly,
},
);
if (callId !== this.lastCallId) {
currentContext.done();
return;
}
this.updateContext(props, currentContext);
}
updateContext(
props: JazzContextManagerProps<Acc>,
context: BrowserGuestContext | BrowserContext<Acc>,
) {
this.context?.done();
this.context = context;
this.props = props;
this.value = {
...context,
refreshContext: this.refreshContext,
logOut: this.logOut,
AccountSchema:
props.AccountSchema ?? (Account as unknown as AccountClass<Acc>),
};
this.notify();
}
propsChanged(props: JazzContextManagerProps<Acc>) {
if (!this.props) {
return true;
}
return (
props.auth !== this.props.auth ||
props.peer !== this.props.peer ||
props.storage !== this.props.storage
);
}
getCurrentValue() {
return this.value;
}
toggleNetwork = (enabled: boolean) => {
if (!this.context || !this.props) {
return;
}
this.context.toggleNetwork?.(enabled);
this.props.localOnly = enabled;
};
logOut = () => {
if (!this.context || !this.props) {
return;
}
this.context.logOut();
return this.createContext(this.props);
};
done = () => {
if (!this.context) {
return;
}
this.context.done();
};
refreshContext = () => {
if (!this.context || !this.props) {
return;
}
return this.createContext(this.props);
};
listeners = new Set<() => void>();
subscribe = (callback: () => void) => {
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
};
notify() {
for (const listener of this.listeners) {
listener();
}
}
}
/** @category Context & Hooks */
export function JazzProvider<Acc extends Account = RegisteredAccount>({
children,
@@ -28,90 +158,51 @@ export function JazzProvider<Acc extends Account = RegisteredAccount>({
peer,
storage,
AccountSchema = Account as unknown as AccountClass<Acc>,
localOnly: localOnlyProp,
}: JazzProviderProps<Acc>) {
const [ctx, setCtx] = useState<JazzContextType<Acc> | undefined>();
const [contextManager] = React.useState(() => new JazzContextManager<Acc>());
const [sessionCount, setSessionCount] = useState(0);
const isAnonymousUser = useIsAnonymousUser();
const localOnly =
localOnlyProp === "anonymous" ? isAnonymousUser : localOnlyProp;
const effectExecuted = useRef(false);
effectExecuted.current = false;
useEffect(
() => {
// Avoid double execution of the effect in development mode for easier debugging.
if (process.env.NODE_ENV === "development") {
if (effectExecuted.current) {
return;
const value = React.useSyncExternalStore<JazzContextType<Acc> | undefined>(
React.useCallback(
(callback) => {
const props = { AccountSchema, auth, peer, storage, localOnly };
if (contextManager.propsChanged(props)) {
contextManager.createContext(props).catch((error) => {
console.error("Error creating Jazz browser context:", error);
});
}
effectExecuted.current = true;
// In development mode we don't return a cleanup function because otherwise
// the double effect execution would mark the context as done immediately.
//
// So we mark it as done in the subsequent execution.
const previousContext = ctx;
if (previousContext) {
previousContext.done();
}
}
async function createContext() {
const currentContext = await createJazzBrowserContext<Acc>(
auth === "guest"
? {
peer,
storage,
}
: {
AccountSchema,
auth,
peer,
storage,
},
);
const logOut = () => {
currentContext.logOut();
setCtx(undefined);
setSessionCount(sessionCount + 1);
if (process.env.NODE_ENV === "development") {
// In development mode we don't return a cleanup function
// so we mark the context as done here.
currentContext.done();
}
};
setCtx({
...currentContext,
AccountSchema,
logOut,
});
return currentContext;
}
const promise = createContext();
promise.catch((e) => {
console.error("Error creating Jazz context", e);
});
// In development mode we don't return a cleanup function because otherwise
// the double effect execution would mark the context as done immediately.
if (process.env.NODE_ENV === "development") {
return;
}
return () => {
void promise.then((context) => context.done());
};
},
[AccountSchema, auth, peer, sessionCount].concat(storage as any),
return contextManager.subscribe(callback);
},
[AccountSchema, auth, peer].concat(storage as any),
),
() => contextManager.getCurrentValue(),
() => contextManager.getCurrentValue(),
);
useEffect(() => {
// In development mode we don't return a cleanup function because otherwise
// the double effect execution would mark the context as done immediately.
if (process.env.NODE_ENV === "development") return;
return () => {
contextManager.done();
};
}, []);
useEffect(() => {
if (contextManager) {
contextManager.toggleNetwork?.(!localOnly);
}
}, [value, localOnly]);
return (
<JazzContext.Provider value={ctx}>{ctx && children}</JazzContext.Provider>
<JazzContext.Provider value={value}>
{value && children}
</JazzContext.Provider>
);
}

View File

@@ -1,8 +1,9 @@
// @vitest-environment happy-dom
import { act, renderHook } from "@testing-library/react";
import { PureJSCrypto } from "cojson/crypto";
import { Account, ID } from "jazz-tools";
import { beforeEach, describe, expect, it } from "vitest";
import { useOnboardingAuth } from "../auth/OnboardingAuth";
import { useOnboardingAuth } from "../auth/AnonymousAuth";
const STORAGE_KEY = "jazz-logged-in-secret";
@@ -10,6 +11,8 @@ beforeEach(() => {
localStorage.removeItem(STORAGE_KEY);
});
const crypto = await PureJSCrypto.create();
describe("useOnboardingAuth", () => {
it("should initialize with loading state", () => {
const { result } = renderHook(() => useOnboardingAuth());
@@ -40,7 +43,7 @@ describe("useOnboardingAuth", () => {
// Start the auth process
await act(async () => {
const authResult = await result.current[0].start();
const authResult = await result.current[0].start(crypto);
authResult.onSuccess();
});
@@ -53,7 +56,7 @@ describe("useOnboardingAuth", () => {
// Sign in first
await act(async () => {
const authResult = await result.current[0].start();
const authResult = await result.current[0].start(crypto);
if (authResult.type === "new") {
await authResult.saveCredentials({
accountID: "test-account-id" as ID<Account>,
@@ -74,7 +77,7 @@ describe("useOnboardingAuth", () => {
const { result: result2 } = renderHook(() => useOnboardingAuth());
await act(async () => {
const authResult = await result2.current[0].start();
const authResult = await result2.current[0].start(crypto);
expect(authResult.type).toBe("new");
});
});
@@ -84,7 +87,7 @@ describe("useOnboardingAuth", () => {
// Sign in
await act(async () => {
const authResult = await result.current[0].start();
const authResult = await result.current[0].start(crypto);
if (authResult.type === "new") {
await authResult.saveCredentials({
accountID: "test-account-id" as ID<Account>,
@@ -106,7 +109,7 @@ describe("useOnboardingAuth", () => {
// Sign in
await act(async () => {
const authResult = await result.current[0].start();
const authResult = await result.current[0].start(crypto);
if (authResult.type === "new") {
await authResult.saveCredentials({
accountID: "test-account-id" as ID<Account>,
@@ -121,7 +124,7 @@ describe("useOnboardingAuth", () => {
const { result: result2 } = renderHook(() => useOnboardingAuth());
await act(async () => {
const authResult = await result2.current[0].start();
const authResult = await result2.current[0].start(crypto);
expect(authResult.type).toBe("existing");
authResult.onSuccess();
});
@@ -134,7 +137,7 @@ describe("useOnboardingAuth", () => {
const { result } = renderHook(() => useOnboardingAuth());
await act(async () => {
const authResult = await result.current[0].start();
const authResult = await result.current[0].start(crypto);
authResult.onError("test-error");
});

View File

@@ -0,0 +1,162 @@
// @vitest-environment happy-dom
import { act, render } from "@testing-library/react";
import {
BrowserContext,
BrowserGuestContext,
createJazzBrowserContext,
} from "jazz-browser";
import { Account } from "jazz-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { JazzProvider } from "../provider";
vi.mock("jazz-browser", async (importOriginal) => {
const actual = await importOriginal<typeof import("jazz-browser")>();
return {
...actual,
createJazzBrowserContext: vi.fn(),
};
});
describe("JazzProvider", () => {
const mockContext = {
me: { id: "test-account" },
done: vi.fn(),
toggleNetwork: vi.fn(),
logOut: vi.fn(),
} as unknown as BrowserContext<Account>;
const mockGuestContext = {
guest: { id: "guest-agent" },
done: vi.fn(),
toggleNetwork: vi.fn(),
logOut: vi.fn(),
} as unknown as BrowserGuestContext;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createJazzBrowserContext).mockReset();
});
it("should create and cleanup context properly", async () => {
vi.mocked(createJazzBrowserContext).mockResolvedValue(mockContext);
const { unmount } = render(
<JazzProvider peer="wss://test.com">
<div>Test Content</div>
</JazzProvider>,
);
// Wait for context creation
await act(async () => {});
expect(createJazzBrowserContext).toHaveBeenCalledWith(
expect.objectContaining({
guest: false,
peer: "wss://test.com",
}),
);
unmount();
expect(mockContext.done).toHaveBeenCalled();
});
it("should handle guest mode correctly", async () => {
vi.mocked(createJazzBrowserContext).mockResolvedValue(mockGuestContext);
render(
<JazzProvider auth="guest" peer="wss://test.com">
<div>Test Content</div>
</JazzProvider>,
);
await act(async () => {});
expect(createJazzBrowserContext).toHaveBeenCalledWith(
expect.objectContaining({
guest: true,
peer: "wss://test.com",
}),
);
});
it("should handle network toggling", async () => {
vi.mocked(createJazzBrowserContext).mockResolvedValue(mockContext);
render(
<JazzProvider auth="guest" peer="wss://test.com" localOnly={true}>
<div>Test Content</div>
</JazzProvider>,
);
await act(async () => {});
expect(createJazzBrowserContext).toHaveBeenCalledWith(
expect.objectContaining({
localOnly: true,
}),
);
});
it("should handle context refresh on props change", async () => {
vi.mocked(createJazzBrowserContext).mockResolvedValue(mockContext);
const { rerender } = render(
<JazzProvider auth="guest" peer="wss://test.com">
<div>Test Content</div>
</JazzProvider>,
);
await act(async () => {});
// Change props
rerender(
<JazzProvider auth="guest" peer="wss://other.com">
<div>Test Content</div>
</JazzProvider>,
);
await act(async () => {});
expect(createJazzBrowserContext).toHaveBeenCalledTimes(2);
expect(mockContext.done).toHaveBeenCalled();
});
it("should not render children until context is ready", async () => {
vi.mocked(createJazzBrowserContext).mockImplementation(
() => new Promise(() => {}), // Never resolves
);
const { container } = render(
<JazzProvider auth="guest" peer="wss://test.com">
<div>Test Content</div>
</JazzProvider>,
);
expect(container.textContent).toBe("");
});
it("should handle context creation errors", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
vi.mocked(createJazzBrowserContext).mockRejectedValue(
new Error("Test error"),
);
render(
<JazzProvider peer="wss://test.com">
<div>Test Content</div>
</JazzProvider>,
);
await act(async () => {});
expect(consoleError).toHaveBeenCalledWith(
"Error creating Jazz browser context:",
expect.any(Error),
);
consoleError.mockRestore();
});
});

View File

@@ -33,13 +33,15 @@
auth === 'guest'
? {
peer,
storage
storage,
guest: true,
}
: {
AccountSchema: AccountSchema ?? Account as unknown as AccountClass<Acc>,
auth,
peer,
storage
storage,
guest: false,
}
).then((context) => {
ctx.current = {

View File

@@ -42,6 +42,11 @@ import { createInboxRoot } from "./inbox.js";
import { Profile } from "./profile.js";
import { RegisteredSchemas } from "./registeredSchemas.js";
export type AccountCreationProps = {
name: string;
onboarding?: boolean;
};
/** @category Identity & Permissions */
export class Account extends CoValueBase implements CoValue {
declare id: ID<this>;
@@ -242,7 +247,7 @@ export class Account extends CoValueBase implements CoValue {
return this.toJSON();
}
async applyMigration(creationProps?: { name: string; onboarding?: boolean }) {
async applyMigration(creationProps?: AccountCreationProps) {
if (creationProps) {
const profileGroup = RegisteredSchemas["Group"].create({ owner: this });
profileGroup.addMember("everyone", "reader");
@@ -267,7 +272,7 @@ export class Account extends CoValueBase implements CoValue {
}
// Placeholder method for subclasses to override
migrate(creationProps?: { name: string }) {
migrate(creationProps?: AccountCreationProps) {
creationProps; // To avoid unused parameter warning
}

View File

@@ -18,6 +18,7 @@ export {
Account,
isControlledAccount,
type AccountClass,
type AccountCreationProps,
} from "./coValues/account.js";
export {
BinaryCoStream,

View File

@@ -23,6 +23,7 @@ export type Credentials = {
export type AuthResult =
| {
type: "existing";
username?: string;
credentials: Credentials;
saveCredentials?: (credentials: Credentials) => Promise<void>;
onSuccess: () => void;

View File

@@ -19,7 +19,7 @@ type TestAccountSchema<Acc extends Account> = CoValueClass<Acc> & {
}) => Promise<Acc>;
};
class TestJSCrypto extends PureJSCrypto {
export class TestJSCrypto extends PureJSCrypto {
static async create() {
if ("navigator" in globalThis && navigator.userAgent.includes("jsdom")) {
// Mocking crypto seal & encrypt to make it work with JSDom. Getting "Error: Uint8Array expected" there

View File

@@ -64,12 +64,13 @@ export const JazzProvider = defineComponent({
try {
const context = await createJazzBrowserContext<RegisteredAccount>(
props.auth === "guest"
? { peer: props.peer, storage: props.storage }
? { peer: props.peer, storage: props.storage, guest: true }
: {
AccountSchema: props.AccountSchema,
auth: props.auth,
peer: props.peer,
storage: props.storage,
guest: false,
},
);
@@ -77,7 +78,7 @@ export const JazzProvider = defineComponent({
...context,
logOut: () => {
logoutHandler.value?.();
// context.logOut();
context.logOut();
key.value += 1;
},
};

113
pnpm-lock.yaml generated
View File

@@ -751,15 +751,24 @@ importers:
examples/music-player:
dependencies:
'@radix-ui/react-dialog':
specifier: ^1.1.4
version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.1
version: 2.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.1.1
version: 2.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot':
specifier: ^1.0.2
specifier: ^1.1.1
version: 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-toast':
specifier: ^1.1.4
version: 1.2.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-tooltip':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
class-variance-authority:
specifier: ^0.7.0
version: 0.7.1
@@ -1580,17 +1589,21 @@ importers:
specifier: ^1.3.0
version: 1.5.0
cojson:
specifier: workspace:0.9.11
specifier: workspace:*
version: link:../cojson
cojson-storage-indexeddb:
specifier: workspace:0.9.11
specifier: workspace:*
version: link:../cojson-storage-indexeddb
cojson-transport-ws:
specifier: workspace:0.9.11
specifier: workspace:*
version: link:../cojson-transport-ws
jazz-tools:
specifier: workspace:0.9.11
specifier: workspace:*
version: link:../jazz-tools
devDependencies:
fake-indexeddb:
specifier: ^6.0.0
version: 6.0.0
typescript:
specifier: ~5.6.2
version: 5.6.3
@@ -4118,6 +4131,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.4':
resolution: {integrity: sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: 18.3.1
react-dom: 18.3.1
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.0':
resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==}
peerDependencies:
@@ -4184,6 +4210,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-label@2.1.1':
resolution: {integrity: sha512-UUw5E4e/2+4kFMH7+YxORXGWggtY6sM8WIwh5RZchhLuUg2H1hc98Py+pr8HMz6rdaYrK2t296ZEjYLOCO5uUw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: 18.3.1
react-dom: 18.3.1
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-menu@2.1.4':
resolution: {integrity: sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A==}
peerDependencies:
@@ -4289,6 +4328,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.1.6':
resolution: {integrity: sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: 18.3.1
react-dom: 18.3.1
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.0':
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
peerDependencies:
@@ -13743,6 +13795,28 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.18
'@radix-ui/react-dialog@1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.0(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-portal': 1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1)
aria-hidden: 1.2.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-remove-scroll: 2.6.2(@types/react@18.3.18)(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.18
'@types/react-dom': 18.3.5(@types/react@18.3.18)
'@radix-ui/react-direction@1.1.0(@types/react@18.3.18)(react@18.3.1)':
dependencies:
react: 18.3.1
@@ -13801,6 +13875,15 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.18
'@radix-ui/react-label@2.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.18
'@types/react-dom': 18.3.5(@types/react@18.3.18)
'@radix-ui/react-menu@2.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.1
@@ -13924,6 +14007,26 @@ snapshots:
'@types/react': 18.3.18
'@types/react-dom': 18.3.5(@types/react@18.3.18)
'@radix-ui/react-tooltip@1.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.0(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-popper': 1.2.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.18
'@types/react-dom': 18.3.5(@types/react@18.3.18)
'@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.18)(react@18.3.1)':
dependencies:
react: 18.3.1

View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
playwright-report

View File

@@ -7,6 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write"
},

File diff suppressed because one or more lines are too long

View File

@@ -1,33 +1,32 @@
import { useAccount } from "jazz-react";
import { useAccount, useIsAnonymousUser } from "jazz-react";
import { AuthButton } from "./AuthButton.tsx";
import { Form } from "./Form.tsx";
import { Logo } from "./Logo.tsx";
function App() {
const { me, logOut } = useAccount({ profile: {}, root: {} });
const { me } = useAccount({ profile: {}, root: {} });
const isAnonymousUser = useIsAnonymousUser();
return (
<>
<header>
<nav className="container flex justify-between items-center py-3">
<span>
You're logged in as <strong>{me?.profile?.name}</strong>
</span>
<button
className="bg-stone-100 py-1.5 px-3 text-sm rounded-md"
onClick={() => logOut()}
>
Log out
</button>
{isAnonymousUser ? (
<span>Authenticate to share the data with another device.</span>
) : (
<span>
You're logged in as <strong>{me?.profile?.name}</strong>
</span>
)}
<AuthButton />
</nav>
</header>
<main className="container mt-16 flex flex-col gap-8">
<Logo />
<div className="text-center">
<h1>
Welcome{me?.profile.firstName ? <>, {me?.profile.firstName}</> : ""}
!
</h1>
<h1>Welcome{me?.profile.name ? <>, {me?.profile.name}</> : ""}!</h1>
{!!me?.root.age && (
<p>As of today, you are {me.root.age} years old.</p>
)}

View File

@@ -0,0 +1,60 @@
"use client";
import { useAccount, useIsAnonymousUser, usePasskeyAuth } from "jazz-react";
import { APPLICATION_NAME } from "./main";
export function AuthButton() {
const { logOut } = useAccount();
const isAnonymousUser = useIsAnonymousUser();
const [, authState] = usePasskeyAuth({
appName: APPLICATION_NAME,
onAnonymousUserUpgrade: ({ isSignUp }) => {
if (isSignUp) {
console.log(
"User signed up using passkeys, the changes done locally are preserved",
);
} else {
console.log(
"User logged in using passkeys, the changes done locally are lost!",
);
}
},
});
function handleLogOut() {
logOut();
window.history.pushState({}, "", "/");
}
if (!isAnonymousUser) {
return (
<button
className="bg-stone-100 py-1.5 px-3 text-sm rounded-md"
onClick={handleLogOut}
>
Log out
</button>
);
}
if (authState.state !== "ready") return null;
return (
<div className="flex gap-2">
<button
className="bg-stone-100 py-1.5 px-3 text-sm rounded-md"
onClick={() => authState.signUp("")}
>
Sign up
</button>
<button
onClick={() => authState.logIn()}
className="bg-stone-100 py-1.5 px-3 text-sm rounded-md"
>
Log in
</button>
</div>
);
}

View File

@@ -9,15 +9,15 @@ export function Form() {
<div className="grid gap-4 border p-8">
<div className="flex items-center gap-3">
<label htmlFor="firstName" className="sm:w-32">
First name
Name
</label>
<input
type="text"
id="firstName"
placeholder="Enter your first name here..."
placeholder="Enter your name here..."
className="border border-stone-300 rounded shadow-sm py-1 px-2 flex-1"
value={me.profile.firstName || ""}
onChange={(e) => (me.profile.firstName = e.target.value)}
value={me.profile.name || ""}
onChange={(e) => (me.profile.name = e.target.value)}
/>
</div>

View File

@@ -1,29 +1,12 @@
import { DemoAuthBasicUI, JazzProvider, useDemoAuth } from "jazz-react";
import { JazzProvider } from "jazz-react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { JazzAccount } from "./schema.ts";
function JazzAndAuth({ children }: { children: React.ReactNode }) {
const [auth, authState] = useDemoAuth();
return (
<>
<JazzProvider
auth={auth}
peer="wss://cloud.jazz.tools/?key=react-demo-auth-tailwind@garden.co"
AccountSchema={JazzAccount}
>
{children}
</JazzProvider>
{authState.state !== "signedIn" && (
<DemoAuthBasicUI appName="React + Demo Auth" state={authState} />
)}
</>
);
}
// We use this to identify the app in the passkey auth
export const APPLICATION_NAME = "Jazz starter";
declare module "jazz-react" {
export interface Register {
@@ -33,8 +16,11 @@ declare module "jazz-react" {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<JazzAndAuth>
<JazzProvider
peer="wss://cloud.jazz.tools/?key=react-demo-auth-tailwind@garden.co"
AccountSchema={JazzAccount}
>
<App />
</JazzAndAuth>
</JazzProvider>
</StrictMode>,
);

View File

@@ -1,40 +0,0 @@
import { Locator, Page, expect } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly signupButton: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByRole("textbox");
this.signupButton = page.getByRole("button", {
name: "Sign up",
});
}
async goto() {
this.page.goto("/");
}
async fillUsername(value: string) {
await this.usernameInput.clear();
await this.usernameInput.fill(value);
}
async loginAs(value: string) {
await this.page
.getByRole("button", {
name: value,
})
.click();
}
async signup() {
await this.signupButton.click();
}
async expectLoaded() {
await expect(this.signupButton).toBeVisible();
}
}

View File

@@ -1,15 +1,9 @@
import { expect, test } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
test("home page loads", async ({ page }) => {
const loginPage = new LoginPage(page);
await page.goto("/");
await expect(page.getByText("Welcome, Anonymous user!")).toBeVisible();
await loginPage.goto();
await loginPage.fillUsername("Alice");
await loginPage.signup();
await expect(page.getByText("Welcome!")).toBeVisible();
await page.getByLabel("First name").fill("Bob");
await page.getByLabel("Name").fill("Bob");
await expect(page.getByText("Welcome, Bob!")).toBeVisible();
});