feat: auth flow
This commit is contained in:
@@ -8,3 +8,6 @@ As an example, we have as high of a bar as a product team like Linear, which out
|
|||||||
|
|
||||||
### Package Manager
|
### Package Manager
|
||||||
- Use **yarn** (not npm) for all dependency management
|
- 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.
|
||||||
|
|||||||
+2
-1
@@ -54,6 +54,7 @@
|
|||||||
"shadcn": "^3.8.5",
|
"shadcn": "^3.8.5",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.0",
|
"tailwindcss": "^4.2.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-21
@@ -1,27 +1,29 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from "react";
|
||||||
import { Button } from '@/components/ui/button';
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { P, H1 } from '@/components/ui/typography';
|
import { LoginPage } from "@/features/auth/login-page";
|
||||||
|
import { HomePage } from "@/pages/home-page";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
useEffect(
|
const status = useAuthStore((s) => s.status);
|
||||||
() => {
|
const restoreSession = useAuthStore((s) => s.restoreSession);
|
||||||
console.log('loaded');
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const [state, updateState] = useState(1);
|
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div>
|
restoreSession();
|
||||||
<H1>Hello World!</H1>
|
}, [restoreSession]);
|
||||||
<P>Welcome to your Electron application.</P>
|
|
||||||
<P>What is the purpose of this app</P>
|
if (status === "idle" || status === "restoring") {
|
||||||
<Button onClick={() => {
|
return (
|
||||||
console.log(state);
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
updateState(state + 1);
|
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
}}>Click me</Button>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
if (status === "authenticated") {
|
||||||
|
return <HomePage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LoginPage />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -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<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestCode(data: RequestCodeRequest): Promise<void> {
|
||||||
|
await this.request<void>("POST", "/auth/request-code", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signIn(data: SignInRequest): Promise<SignInResponse> {
|
||||||
|
return this.request<SignInResponse>("POST", "/auth/sign-in", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(): Promise<Human> {
|
||||||
|
return this.request<Human>("GET", "/auth/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
async signOut(): Promise<void> {
|
||||||
|
await this.request<void>("POST", "/auth/sign-out");
|
||||||
|
}
|
||||||
|
|
||||||
|
async startup(): Promise<StartupResponse> {
|
||||||
|
return this.request<StartupResponse>("GET", "/startup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient({
|
||||||
|
baseUrl: "https://orion.dev.flowy.live",
|
||||||
|
getToken: () => useSessionStore.getState().token,
|
||||||
|
onUnauthorized: () => useSessionStore.getState().clearToken(),
|
||||||
|
});
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -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<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -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 (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<H3>Check your email</H3>
|
||||||
|
<Muted>
|
||||||
|
We sent a code to <strong className="text-foreground">{email}</strong>.
|
||||||
|
</Muted>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="code">Code</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="Enter code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCode(e.target.value);
|
||||||
|
if (error) clearError();
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button type="submit" disabled={isSigningIn || !code}>
|
||||||
|
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<H3>Sign in</H3>
|
||||||
|
<Muted>Enter your email to receive a sign-in code.</Muted>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value);
|
||||||
|
if (error) clearError();
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isRequestingCode || !email}>
|
||||||
|
{isRequestingCode ? "Sending..." : "Continue"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Step>("email");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
{step === "email" ? (
|
||||||
|
<EmailStep
|
||||||
|
onCodeSent={(submittedEmail) => {
|
||||||
|
setEmail(submittedEmail);
|
||||||
|
setStep("code");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CodeStep
|
||||||
|
email={email}
|
||||||
|
onBack={() => setStep("email")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+23
-2
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow, session } from 'electron';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
|
|
||||||
@@ -33,7 +33,28 @@ const createWindow = () => {
|
|||||||
// This method will be called when Electron has finished
|
// This method will be called when Electron has finished
|
||||||
// initialization and is ready to create browser windows.
|
// initialization and is ready to create browser windows.
|
||||||
// Some APIs can only be used after this event occurs.
|
// 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
|
// 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
|
// for applications and their menu bar to stay active until the user quits
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
|
||||||
|
<H1>Welcome, {user?.email_prefix}</H1>
|
||||||
|
<Muted>{user?.email}</Muted>
|
||||||
|
<Button variant="outline" onClick={signOut} disabled={isSigningOut}>
|
||||||
|
{isSigningOut ? "Signing out..." : "Sign out"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
|
startupData: null,
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
fetchStartup: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const data = await apiClient.startup();
|
||||||
|
set({ startupData: data });
|
||||||
|
} finally {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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<void>;
|
||||||
|
requestCode: (email: string) => Promise<void>;
|
||||||
|
signIn: (email: string, code: string) => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<SessionState>((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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -8003,3 +8003,8 @@ zod@^3.24.1:
|
|||||||
version "4.3.6"
|
version "4.3.6"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
|
||||||
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
|
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==
|
||||||
|
|||||||
Reference in New Issue
Block a user