diff --git a/CLAUDE.md b/CLAUDE.md
index 17ac4af..f3078e2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,3 +8,6 @@ As an example, we have as high of a bar as a product team like Linear, which out
### Package Manager
- Use **yarn** (not npm) for all dependency management
+
+### Design system
+Whenever possible, we should use the design system components. If we need to add a new component from the available ones in [shadcn](https://ui.shadcn.com/docs/components), we should add it to the design system (using `shadcn add _`) and use it in the app.
diff --git a/js/package.json b/js/package.json
index 98304f1..260732d 100644
--- a/js/package.json
+++ b/js/package.json
@@ -54,6 +54,7 @@
"shadcn": "^3.8.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "zustand": "^5.0.11"
}
}
diff --git a/js/src/App.tsx b/js/src/App.tsx
index 7d8c3c2..c495695 100644
--- a/js/src/App.tsx
+++ b/js/src/App.tsx
@@ -1,27 +1,29 @@
-import { useEffect, useState } from 'react';
-import { Button } from '@/components/ui/button';
-import { P, H1 } from '@/components/ui/typography';
+import { useEffect } from "react";
+import { useAuthStore } from "@/stores/auth-store";
+import { LoginPage } from "@/features/auth/login-page";
+import { HomePage } from "@/pages/home-page";
const App = () => {
- useEffect(
- () => {
- console.log('loaded');
- },
- []
- );
- const [state, updateState] = useState(1);
+ const status = useAuthStore((s) => s.status);
+ const restoreSession = useAuthStore((s) => s.restoreSession);
- return (
-
-
Hello World!
-
Welcome to your Electron application.
-
What is the purpose of this app
-
{
- console.log(state);
- updateState(state + 1);
- }}>Click me
-
- );
+ useEffect(() => {
+ restoreSession();
+ }, [restoreSession]);
+
+ if (status === "idle" || status === "restoring") {
+ return (
+
+ );
+ }
+
+ if (status === "authenticated") {
+ return ;
+ }
+
+ return ;
};
export default App;
diff --git a/js/src/api/client.ts b/js/src/api/client.ts
new file mode 100644
index 0000000..d294bd1
--- /dev/null
+++ b/js/src/api/client.ts
@@ -0,0 +1,95 @@
+import { useSessionStore } from "@/stores/session-store";
+import type {
+ Human,
+ RequestCodeRequest,
+ SignInRequest,
+ SignInResponse,
+ StartupResponse,
+} from "./types";
+
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
+}
+
+interface ApiClientConfig {
+ baseUrl: string;
+ getToken: () => string | null;
+ onUnauthorized: () => void;
+}
+
+class ApiClient {
+ private config: ApiClientConfig;
+
+ constructor(config: ApiClientConfig) {
+ this.config = config;
+ }
+
+ private async request(
+ method: string,
+ path: string,
+ body?: unknown,
+ ): Promise {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+
+ const token = this.config.getToken();
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+
+ const response = await fetch(`${this.config.baseUrl}${path}`, {
+ method,
+ headers,
+ body: body ? JSON.stringify(body) : undefined,
+ });
+
+ if (response.status === 401) {
+ this.config.onUnauthorized();
+ throw new ApiError(401, "Unauthorized");
+ }
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => "Unknown error");
+ throw new ApiError(response.status, text);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return response.json() as Promise;
+ }
+
+ async requestCode(data: RequestCodeRequest): Promise {
+ await this.request("POST", "/auth/request-code", data);
+ }
+
+ async signIn(data: SignInRequest): Promise {
+ return this.request("POST", "/auth/sign-in", data);
+ }
+
+ async me(): Promise {
+ return this.request("GET", "/auth/me");
+ }
+
+ async signOut(): Promise {
+ await this.request("POST", "/auth/sign-out");
+ }
+
+ async startup(): Promise {
+ return this.request("GET", "/startup");
+ }
+}
+
+export const apiClient = new ApiClient({
+ baseUrl: "https://orion.dev.flowy.live",
+ getToken: () => useSessionStore.getState().token,
+ onUnauthorized: () => useSessionStore.getState().clearToken(),
+});
diff --git a/js/src/api/types.ts b/js/src/api/types.ts
new file mode 100644
index 0000000..12f03ab
--- /dev/null
+++ b/js/src/api/types.ts
@@ -0,0 +1,25 @@
+export interface Human {
+ id: string;
+ email: string;
+ email_prefix: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface RequestCodeRequest {
+ email: string;
+}
+
+export interface SignInRequest {
+ email: string;
+ code: string;
+}
+
+export interface SignInResponse {
+ human: Human;
+ token: string;
+}
+
+export interface StartupResponse {
+ networks: unknown[];
+}
diff --git a/js/src/components/ui/input.tsx b/js/src/components/ui/input.tsx
new file mode 100644
index 0000000..d591c47
--- /dev/null
+++ b/js/src/components/ui/input.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/js/src/components/ui/label.tsx b/js/src/components/ui/label.tsx
new file mode 100644
index 0000000..33dc071
--- /dev/null
+++ b/js/src/components/ui/label.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+import { Label as LabelPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/js/src/features/auth/code-step.tsx b/js/src/features/auth/code-step.tsx
new file mode 100644
index 0000000..087ec1b
--- /dev/null
+++ b/js/src/features/auth/code-step.tsx
@@ -0,0 +1,69 @@
+import { type FormEvent, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { H3, Muted } from "@/components/ui/typography";
+import { useAuthStore } from "@/stores/auth-store";
+
+interface CodeStepProps {
+ email: string;
+ onBack: () => void;
+}
+
+export function CodeStep({ email, onBack }: CodeStepProps) {
+ const [code, setCode] = useState("");
+ const isSigningIn = useAuthStore((s) => s.isSigningIn);
+ const error = useAuthStore((s) => s.error);
+ const signIn = useAuthStore((s) => s.signIn);
+ const clearError = useAuthStore((s) => s.clearError);
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ try {
+ await signIn(email, code);
+ } catch {
+ // Error is set in the store
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/js/src/features/auth/email-step.tsx b/js/src/features/auth/email-step.tsx
new file mode 100644
index 0000000..9008cb7
--- /dev/null
+++ b/js/src/features/auth/email-step.tsx
@@ -0,0 +1,61 @@
+import { type FormEvent, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { H3, Muted } from "@/components/ui/typography";
+import { useAuthStore } from "@/stores/auth-store";
+
+interface EmailStepProps {
+ onCodeSent: (email: string) => void;
+}
+
+export function EmailStep({ onCodeSent }: EmailStepProps) {
+ const [email, setEmail] = useState("");
+ const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
+ const error = useAuthStore((s) => s.error);
+ const requestCode = useAuthStore((s) => s.requestCode);
+ const clearError = useAuthStore((s) => s.clearError);
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ try {
+ await requestCode(email);
+ onCodeSent(email);
+ } catch {
+ // Error is set in the store
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/js/src/features/auth/login-page.tsx b/js/src/features/auth/login-page.tsx
new file mode 100644
index 0000000..c4b2686
--- /dev/null
+++ b/js/src/features/auth/login-page.tsx
@@ -0,0 +1,30 @@
+import { useState } from "react";
+import { EmailStep } from "./email-step";
+import { CodeStep } from "./code-step";
+
+type Step = "email" | "code";
+
+export function LoginPage() {
+ const [step, setStep] = useState("email");
+ const [email, setEmail] = useState("");
+
+ return (
+
+
+ {step === "email" ? (
+ {
+ setEmail(submittedEmail);
+ setStep("code");
+ }}
+ />
+ ) : (
+ setStep("email")}
+ />
+ )}
+
+
+ );
+}
diff --git a/js/src/main.ts b/js/src/main.ts
index f4f001e..8ca5c0f 100644
--- a/js/src/main.ts
+++ b/js/src/main.ts
@@ -1,4 +1,4 @@
-import { app, BrowserWindow } from 'electron';
+import { app, BrowserWindow, session } from 'electron';
import path from 'node:path';
import started from 'electron-squirrel-startup';
@@ -33,7 +33,28 @@ const createWindow = () => {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
-app.on('ready', createWindow);
+app.on('ready', () => {
+ // Allow CORS for API requests from the renderer process.
+ // The server doesn't handle OPTIONS preflight, so we intercept at the
+ // Electron network layer: inject CORS headers and return 200 for preflight.
+ session.defaultSession.webRequest.onHeadersReceived(
+ { urls: ['https://orion.dev.flowy.live/*'] },
+ (details, callback) => {
+ const headers = { ...details.responseHeaders };
+ headers['access-control-allow-origin'] = ['*'];
+ headers['access-control-allow-headers'] = ['Content-Type', 'Authorization'];
+ headers['access-control-allow-methods'] = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'];
+
+ if (details.method === 'OPTIONS') {
+ callback({ responseHeaders: headers, statusLine: 'HTTP/1.1 200 OK' });
+ } else {
+ callback({ responseHeaders: headers });
+ }
+ },
+ );
+
+ createWindow();
+});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
diff --git a/js/src/pages/home-page.tsx b/js/src/pages/home-page.tsx
new file mode 100644
index 0000000..2e6d9a7
--- /dev/null
+++ b/js/src/pages/home-page.tsx
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { H1, Muted } from "@/components/ui/typography";
+import { useAuthStore } from "@/stores/auth-store";
+import { useAppStore } from "@/stores/app-store";
+
+export function HomePage() {
+ const user = useAuthStore((s) => s.user);
+ const isSigningOut = useAuthStore((s) => s.isSigningOut);
+ const signOut = useAuthStore((s) => s.signOut);
+ const fetchStartup = useAppStore((s) => s.fetchStartup);
+
+ useEffect(() => {
+ fetchStartup();
+ }, [fetchStartup]);
+
+ return (
+
+
Welcome, {user?.email_prefix}
+ {user?.email}
+
+ {isSigningOut ? "Signing out..." : "Sign out"}
+
+
+ );
+}
diff --git a/js/src/stores/app-store.ts b/js/src/stores/app-store.ts
new file mode 100644
index 0000000..822cf87
--- /dev/null
+++ b/js/src/stores/app-store.ts
@@ -0,0 +1,24 @@
+import { create } from "zustand";
+import { apiClient } from "@/api/client";
+import type { StartupResponse } from "@/api/types";
+
+interface AppState {
+ startupData: StartupResponse | null;
+ isLoading: boolean;
+ fetchStartup: () => Promise;
+}
+
+export const useAppStore = create((set) => ({
+ startupData: null,
+ isLoading: false,
+
+ fetchStartup: async () => {
+ set({ isLoading: true });
+ try {
+ const data = await apiClient.startup();
+ set({ startupData: data });
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+}));
diff --git a/js/src/stores/auth-store.ts b/js/src/stores/auth-store.ts
new file mode 100644
index 0000000..252b5bd
--- /dev/null
+++ b/js/src/stores/auth-store.ts
@@ -0,0 +1,109 @@
+import { create } from "zustand";
+import { apiClient, ApiError } from "@/api/client";
+import type { Human } from "@/api/types";
+import { useSessionStore } from "./session-store";
+
+type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated";
+
+interface AuthState {
+ status: AuthStatus;
+ user: Human | null;
+ isRequestingCode: boolean;
+ isSigningIn: boolean;
+ isSigningOut: boolean;
+ error: string | null;
+ restoreSession: () => Promise;
+ requestCode: (email: string) => Promise;
+ signIn: (email: string, code: string) => Promise;
+ signOut: () => Promise;
+ clearError: () => void;
+}
+
+export const useAuthStore = create((set) => ({
+ status: "idle",
+ user: null,
+ isRequestingCode: false,
+ isSigningIn: false,
+ isSigningOut: false,
+ error: null,
+
+ restoreSession: async () => {
+ const token = useSessionStore.getState().token;
+ if (!token) {
+ set({ status: "unauthenticated" });
+ return;
+ }
+
+ set({ status: "restoring" });
+ try {
+ const user = await apiClient.me();
+ set({ status: "authenticated", user });
+ } catch {
+ useSessionStore.getState().clearToken();
+ set({ status: "unauthenticated", user: null });
+ }
+ },
+
+ requestCode: async (email: string) => {
+ set({ isRequestingCode: true, error: null });
+ try {
+ await apiClient.requestCode({ email });
+ } catch (e) {
+ const message =
+ e instanceof ApiError ? e.message : "Failed to send code";
+ set({ error: message });
+ throw e;
+ } finally {
+ set({ isRequestingCode: false });
+ }
+ },
+
+ signIn: async (email: string, code: string) => {
+ set({ isSigningIn: true, error: null });
+ try {
+ const { human, token } = await apiClient.signIn({ email, code });
+ useSessionStore.getState().setToken(token);
+ set({ status: "authenticated", user: human });
+ } catch (e) {
+ const message =
+ e instanceof ApiError ? e.message : "Failed to sign in";
+ set({ error: message });
+ throw e;
+ } finally {
+ set({ isSigningIn: false });
+ }
+ },
+
+ signOut: async () => {
+ set({ isSigningOut: true });
+ try {
+ await apiClient.signOut();
+ } catch {
+ // Best-effort — sign out locally regardless
+ } finally {
+ useSessionStore.getState().clearToken();
+ set({
+ status: "unauthenticated",
+ user: null,
+ isSigningOut: false,
+ error: null,
+ });
+ }
+ },
+
+ clearError: () => set({ error: null }),
+}));
+
+// React to token being cleared externally (e.g. 401 from API client)
+useSessionStore.subscribe((state, prevState) => {
+ if (prevState.token && !state.token) {
+ const authState = useAuthStore.getState();
+ if (authState.status === "authenticated") {
+ useAuthStore.setState({
+ status: "unauthenticated",
+ user: null,
+ error: null,
+ });
+ }
+ }
+});
diff --git a/js/src/stores/session-store.ts b/js/src/stores/session-store.ts
new file mode 100644
index 0000000..b00e154
--- /dev/null
+++ b/js/src/stores/session-store.ts
@@ -0,0 +1,23 @@
+import { create } from "zustand";
+
+const AUTH_TOKEN_KEY = "auth_token";
+
+interface SessionState {
+ token: string | null;
+ setToken: (token: string) => void;
+ clearToken: () => void;
+}
+
+export const useSessionStore = create((set) => ({
+ token: localStorage.getItem(AUTH_TOKEN_KEY),
+
+ setToken: (token: string) => {
+ localStorage.setItem(AUTH_TOKEN_KEY, token);
+ set({ token });
+ },
+
+ clearToken: () => {
+ localStorage.removeItem(AUTH_TOKEN_KEY);
+ set({ token: null });
+ },
+}));
diff --git a/js/yarn.lock b/js/yarn.lock
index d30332a..4da0b37 100644
--- a/js/yarn.lock
+++ b/js/yarn.lock
@@ -8003,3 +8003,8 @@ zod@^3.24.1:
version "4.3.6"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
+
+zustand@^5.0.11:
+ version "5.0.11"
+ resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494"
+ integrity sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==