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

- -
- ); + useEffect(() => { + restoreSession(); + }, [restoreSession]); + + if (status === "idle" || status === "restoring") { + return ( +
+

Loading...

+
+ ); + } + + 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 ( +
+
+

Check your email

+ + We sent a code to {email}. + +
+ +
+ + { + setCode(e.target.value); + if (error) clearError(); + }} + required + autoFocus + /> +
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+ ); +} 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 ( +
+
+

Sign in

+ Enter your email to receive a sign-in code. +
+ +
+ + { + setEmail(e.target.value); + if (error) clearError(); + }} + required + autoFocus + /> +
+ + {error && ( +

{error}

+ )} + + +
+ ); +} 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} + +
+ ); +} 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==