@useatlas/react 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +95 -0
  2. package/dist/chunk-2WFDP7G5.js +231 -0
  3. package/dist/chunk-2WFDP7G5.js.map +1 -0
  4. package/dist/chunk-44HBZYKP.js +224 -0
  5. package/dist/chunk-44HBZYKP.js.map +1 -0
  6. package/dist/chunk-5SEVKHS5.cjs +229 -0
  7. package/dist/chunk-5SEVKHS5.cjs.map +1 -0
  8. package/dist/chunk-UIRB6L36.cjs +249 -0
  9. package/dist/chunk-UIRB6L36.cjs.map +1 -0
  10. package/dist/hooks.cjs +251 -0
  11. package/dist/hooks.cjs.map +1 -0
  12. package/dist/hooks.d.cts +132 -0
  13. package/dist/hooks.d.ts +132 -0
  14. package/dist/hooks.js +237 -0
  15. package/dist/hooks.js.map +1 -0
  16. package/dist/index.cjs +2976 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +69 -0
  19. package/dist/index.d.ts +69 -0
  20. package/dist/index.js +2926 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/result-chart-NFAJ4IQ5.js +398 -0
  23. package/dist/result-chart-NFAJ4IQ5.js.map +1 -0
  24. package/dist/result-chart-YLCKBNV4.cjs +400 -0
  25. package/dist/result-chart-YLCKBNV4.cjs.map +1 -0
  26. package/dist/styles.css +59 -0
  27. package/dist/use-dark-mode-rFxawUv1.d.cts +123 -0
  28. package/dist/use-dark-mode-rFxawUv1.d.ts +123 -0
  29. package/dist/widget.css +2 -0
  30. package/dist/widget.js +445 -0
  31. package/package.json +113 -0
  32. package/src/components/__tests__/tool-renderers.test.tsx +239 -0
  33. package/src/components/actions/action-approval-card.tsx +296 -0
  34. package/src/components/actions/action-status-badge.tsx +50 -0
  35. package/src/components/admin/change-password-dialog.tsx +128 -0
  36. package/src/components/atlas-chat.tsx +656 -0
  37. package/src/components/chart/chart-detection.ts +318 -0
  38. package/src/components/chart/result-chart.tsx +590 -0
  39. package/src/components/chat/api-key-bar.tsx +66 -0
  40. package/src/components/chat/copy-button.tsx +25 -0
  41. package/src/components/chat/data-table.tsx +104 -0
  42. package/src/components/chat/error-banner.tsx +32 -0
  43. package/src/components/chat/explore-card.tsx +41 -0
  44. package/src/components/chat/follow-up-chips.tsx +29 -0
  45. package/src/components/chat/loading-card.tsx +10 -0
  46. package/src/components/chat/managed-auth-card.tsx +116 -0
  47. package/src/components/chat/markdown.tsx +146 -0
  48. package/src/components/chat/python-result-card.tsx +245 -0
  49. package/src/components/chat/sql-block.tsx +54 -0
  50. package/src/components/chat/sql-result-card.tsx +163 -0
  51. package/src/components/chat/starter-prompts.ts +6 -0
  52. package/src/components/chat/tool-part.tsx +106 -0
  53. package/src/components/chat/typing-indicator.tsx +22 -0
  54. package/src/components/conversations/conversation-item.tsx +135 -0
  55. package/src/components/conversations/conversation-list.tsx +69 -0
  56. package/src/components/conversations/conversation-sidebar.tsx +113 -0
  57. package/src/components/conversations/delete-confirmation.tsx +27 -0
  58. package/src/components/schema-explorer/schema-explorer.tsx +517 -0
  59. package/src/components/ui/alert-dialog.tsx +196 -0
  60. package/src/components/ui/badge.tsx +48 -0
  61. package/src/components/ui/button.tsx +64 -0
  62. package/src/components/ui/card.tsx +92 -0
  63. package/src/components/ui/dialog.tsx +158 -0
  64. package/src/components/ui/dropdown-menu.tsx +257 -0
  65. package/src/components/ui/input.tsx +21 -0
  66. package/src/components/ui/label.tsx +24 -0
  67. package/src/components/ui/scroll-area.tsx +62 -0
  68. package/src/components/ui/separator.tsx +28 -0
  69. package/src/components/ui/sheet.tsx +143 -0
  70. package/src/components/ui/table.tsx +116 -0
  71. package/src/components/ui/toggle-group.tsx +83 -0
  72. package/src/components/ui/toggle.tsx +47 -0
  73. package/src/context.tsx +85 -0
  74. package/src/env.d.ts +9 -0
  75. package/src/hooks/__tests__/provider.test.tsx +83 -0
  76. package/src/hooks/__tests__/use-atlas-auth.test.tsx +283 -0
  77. package/src/hooks/__tests__/use-atlas-chat.test.tsx +157 -0
  78. package/src/hooks/__tests__/use-atlas-conversations.test.tsx +159 -0
  79. package/src/hooks/__tests__/use-atlas-theme.test.tsx +56 -0
  80. package/src/hooks/index.ts +47 -0
  81. package/src/hooks/provider.tsx +77 -0
  82. package/src/hooks/theme-init-script.ts +17 -0
  83. package/src/hooks/use-atlas-auth.ts +131 -0
  84. package/src/hooks/use-atlas-chat.ts +102 -0
  85. package/src/hooks/use-atlas-conversations.ts +61 -0
  86. package/src/hooks/use-atlas-theme.ts +34 -0
  87. package/src/hooks/use-conversations.ts +189 -0
  88. package/src/hooks/use-dark-mode.ts +150 -0
  89. package/src/index.ts +36 -0
  90. package/src/lib/action-types.ts +11 -0
  91. package/src/lib/helpers.ts +198 -0
  92. package/src/lib/tool-renderer-types.ts +76 -0
  93. package/src/lib/types.ts +29 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/styles.css +59 -0
  96. package/src/test-setup.ts +55 -0
  97. package/src/widget-entry.ts +20 -0
  98. package/src/widget.css +12 -0
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import { AtlasProvider } from "../provider";
4
+ import { useAtlasChat } from "../use-atlas-chat";
5
+ import type { ReactNode } from "react";
6
+
7
+ // Mock @ai-sdk/react useChat
8
+ const mockSendMessage = mock(() => Promise.resolve());
9
+ const mockSetMessages = mock();
10
+
11
+ mock.module("@ai-sdk/react", () => ({
12
+ useChat: () => ({
13
+ messages: [],
14
+ setMessages: mockSetMessages,
15
+ sendMessage: mockSendMessage,
16
+ status: "ready",
17
+ error: undefined,
18
+ }),
19
+ }));
20
+
21
+ mock.module("ai", () => ({
22
+ DefaultChatTransport: class {
23
+ constructor(public opts: Record<string, unknown>) {}
24
+ },
25
+ isToolUIPart: () => false,
26
+ getToolName: () => "unknown",
27
+ callCompletionApi: () => {},
28
+ callChatApi: () => {},
29
+ }));
30
+
31
+ const originalFetch = globalThis.fetch;
32
+ const fetchMock = mock(() =>
33
+ Promise.resolve(
34
+ new Response("", { status: 200, headers: { "Content-Type": "application/json" } }),
35
+ ),
36
+ );
37
+
38
+ beforeEach(() => {
39
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
40
+ fetchMock.mockClear();
41
+ mockSendMessage.mockClear();
42
+ mockSetMessages.mockClear();
43
+ mockSendMessage.mockImplementation(() => Promise.resolve());
44
+ });
45
+
46
+ afterEach(() => {
47
+ globalThis.fetch = originalFetch;
48
+ });
49
+
50
+ function wrapper({ children }: { children: ReactNode }) {
51
+ return (
52
+ <AtlasProvider apiUrl="https://api.example.com" apiKey="test-key">
53
+ {children}
54
+ </AtlasProvider>
55
+ );
56
+ }
57
+
58
+ describe("useAtlasChat", () => {
59
+ it("returns initial state with empty messages", () => {
60
+ const { result } = renderHook(() => useAtlasChat(), { wrapper });
61
+
62
+ expect(result.current.messages).toEqual([]);
63
+ expect(result.current.input).toBe("");
64
+ expect(result.current.status).toBe("ready");
65
+ expect(result.current.isLoading).toBe(false);
66
+ expect(result.current.error).toBeNull();
67
+ expect(result.current.conversationId).toBeNull();
68
+ });
69
+
70
+ it("accepts initialConversationId option", () => {
71
+ const { result } = renderHook(
72
+ () => useAtlasChat({ initialConversationId: "conv-123" }),
73
+ { wrapper },
74
+ );
75
+
76
+ expect(result.current.conversationId).toBe("conv-123");
77
+ });
78
+
79
+ it("setInput updates the input value", () => {
80
+ const { result } = renderHook(() => useAtlasChat(), { wrapper });
81
+
82
+ act(() => {
83
+ result.current.setInput("hello");
84
+ });
85
+
86
+ expect(result.current.input).toBe("hello");
87
+ });
88
+
89
+ it("sendMessage clears input and delegates to useChat", async () => {
90
+ const { result } = renderHook(() => useAtlasChat(), { wrapper });
91
+
92
+ act(() => {
93
+ result.current.setInput("test query");
94
+ });
95
+
96
+ await act(async () => {
97
+ await result.current.sendMessage("test query");
98
+ });
99
+
100
+ expect(result.current.input).toBe("");
101
+ expect(mockSendMessage).toHaveBeenCalledWith({ text: "test query" });
102
+ });
103
+
104
+ it("sendMessage restores input on failure", async () => {
105
+ mockSendMessage.mockImplementation(() =>
106
+ Promise.reject(new Error("Stream failed")),
107
+ );
108
+
109
+ const { result } = renderHook(() => useAtlasChat(), { wrapper });
110
+
111
+ act(() => {
112
+ result.current.setInput("my query");
113
+ });
114
+
115
+ let caught: Error | null = null;
116
+ await act(async () => {
117
+ try {
118
+ await result.current.sendMessage("my query");
119
+ } catch (e) {
120
+ caught = e as Error;
121
+ }
122
+ });
123
+
124
+ expect(caught).not.toBeNull();
125
+ expect(caught!.message).toBe("Stream failed");
126
+ expect(result.current.input).toBe("my query");
127
+ });
128
+
129
+ it("setConversationId updates conversation ID", () => {
130
+ const { result } = renderHook(() => useAtlasChat(), { wrapper });
131
+
132
+ act(() => {
133
+ result.current.setConversationId("new-conv");
134
+ });
135
+
136
+ expect(result.current.conversationId).toBe("new-conv");
137
+ });
138
+
139
+ it("setMessages is exposed from the hook", () => {
140
+ const { result } = renderHook(() => useAtlasChat(), { wrapper });
141
+
142
+ expect(result.current.setMessages).toBeDefined();
143
+ expect(typeof result.current.setMessages).toBe("function");
144
+ });
145
+
146
+ it("exposes isLoading derived from status", () => {
147
+ // With status "ready", isLoading should be false
148
+ const { result } = renderHook(() => useAtlasChat(), { wrapper });
149
+ expect(result.current.isLoading).toBe(false);
150
+ expect(result.current.status).toBe("ready");
151
+ });
152
+
153
+ it("returns null error when useChat has no error", () => {
154
+ const { result } = renderHook(() => useAtlasChat(), { wrapper });
155
+ expect(result.current.error).toBeNull();
156
+ });
157
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
2
+ import { renderHook, waitFor, act } from "@testing-library/react";
3
+ import { AtlasProvider } from "../provider";
4
+ import { useAtlasConversations } from "../use-atlas-conversations";
5
+ import type { ReactNode } from "react";
6
+
7
+ const mockConversations = [
8
+ {
9
+ id: "conv-1",
10
+ userId: "user-1",
11
+ title: "Test conversation",
12
+ surface: "web",
13
+ connectionId: null,
14
+ starred: false,
15
+ createdAt: "2026-03-12T00:00:00Z",
16
+ updatedAt: "2026-03-12T00:00:00Z",
17
+ },
18
+ ];
19
+
20
+ const fetchMock = mock(() =>
21
+ Promise.resolve(
22
+ new Response(
23
+ JSON.stringify({ conversations: mockConversations, total: 1 }),
24
+ { status: 200, headers: { "Content-Type": "application/json" } },
25
+ ),
26
+ ),
27
+ );
28
+
29
+ const originalFetch = globalThis.fetch;
30
+
31
+ beforeEach(() => {
32
+ // mockClear only clears call history, not implementation — restore the default
33
+ fetchMock.mockImplementation(() =>
34
+ Promise.resolve(
35
+ new Response(
36
+ JSON.stringify({ conversations: mockConversations, total: 1 }),
37
+ { status: 200, headers: { "Content-Type": "application/json" } },
38
+ ),
39
+ ),
40
+ );
41
+ fetchMock.mockClear();
42
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
43
+ });
44
+
45
+ afterEach(() => {
46
+ globalThis.fetch = originalFetch;
47
+ });
48
+
49
+ function wrapper({ children }: { children: ReactNode }) {
50
+ return (
51
+ <AtlasProvider apiUrl="https://api.example.com" apiKey="test-key">
52
+ {children}
53
+ </AtlasProvider>
54
+ );
55
+ }
56
+
57
+ describe("useAtlasConversations", () => {
58
+ it("initializes with empty state", () => {
59
+ const { result } = renderHook(
60
+ () => useAtlasConversations({ enabled: false }),
61
+ { wrapper },
62
+ );
63
+
64
+ expect(result.current.conversations).toEqual([]);
65
+ expect(result.current.total).toBe(0);
66
+ expect(result.current.isLoading).toBe(false);
67
+ expect(result.current.available).toBe(true);
68
+ expect(result.current.selectedId).toBeNull();
69
+ });
70
+
71
+ it("fetches conversations with correct auth headers", async () => {
72
+ const { result } = renderHook(
73
+ () => useAtlasConversations({ enabled: true }),
74
+ { wrapper },
75
+ );
76
+
77
+ await result.current.refresh();
78
+
79
+ await waitFor(() => {
80
+ expect(result.current.conversations).toHaveLength(1);
81
+ });
82
+
83
+ expect(result.current.conversations[0].id).toBe("conv-1");
84
+ expect(result.current.total).toBe(1);
85
+
86
+ // Verify fetch called with correct URL and auth header
87
+ const calls = fetchMock.mock.calls as unknown as [string, RequestInit][];
88
+ expect(calls[0][0]).toBe("https://api.example.com/api/v1/conversations?limit=50");
89
+ const headers = calls[0][1]?.headers as Record<string, string>;
90
+ expect(headers["Authorization"]).toBe("Bearer test-key");
91
+ });
92
+
93
+ it("disables on 404 response", async () => {
94
+ fetchMock.mockImplementation(() =>
95
+ Promise.resolve(new Response("", { status: 404 })),
96
+ );
97
+
98
+ const { result } = renderHook(
99
+ () => useAtlasConversations({ enabled: true }),
100
+ { wrapper },
101
+ );
102
+
103
+ await result.current.refresh();
104
+
105
+ await waitFor(() => {
106
+ expect(result.current.available).toBe(false);
107
+ });
108
+ });
109
+
110
+ it("deleteConversation removes from local state", async () => {
111
+ const { result } = renderHook(
112
+ () => useAtlasConversations({ enabled: true }),
113
+ { wrapper },
114
+ );
115
+
116
+ await act(async () => {
117
+ await result.current.refresh();
118
+ });
119
+ expect(result.current.conversations).toHaveLength(1);
120
+
121
+ // Mock DELETE response
122
+ fetchMock.mockImplementation(() =>
123
+ Promise.resolve(new Response("", { status: 200 })),
124
+ );
125
+
126
+ let deleted = false;
127
+ await act(async () => {
128
+ deleted = await result.current.deleteConversation("conv-1");
129
+ });
130
+ expect(deleted).toBe(true);
131
+ expect(result.current.conversations).toHaveLength(0);
132
+ expect(result.current.total).toBe(0);
133
+ });
134
+
135
+ it("starConversation does optimistic update and rolls back on failure", async () => {
136
+ const { result } = renderHook(
137
+ () => useAtlasConversations({ enabled: true }),
138
+ { wrapper },
139
+ );
140
+
141
+ await act(async () => {
142
+ await result.current.refresh();
143
+ });
144
+ expect(result.current.conversations).toHaveLength(1);
145
+
146
+ // Mock PATCH to fail
147
+ fetchMock.mockImplementation(() =>
148
+ Promise.resolve(new Response("", { status: 500 })),
149
+ );
150
+
151
+ let starred = true;
152
+ await act(async () => {
153
+ starred = await result.current.starConversation("conv-1", true);
154
+ });
155
+ expect(starred).toBe(false);
156
+ // Should roll back to unstarred
157
+ expect(result.current.conversations[0].starred).toBe(false);
158
+ });
159
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import { useAtlasTheme } from "../use-atlas-theme";
4
+
5
+ describe("useAtlasTheme", () => {
6
+ beforeEach(() => {
7
+ const { result } = renderHook(() => useAtlasTheme());
8
+ act(() => {
9
+ result.current.setTheme("system");
10
+ });
11
+ });
12
+
13
+ it("returns current theme mode", () => {
14
+ const { result } = renderHook(() => useAtlasTheme());
15
+ expect(["light", "dark", "system"]).toContain(result.current.theme);
16
+ expect(typeof result.current.isDark).toBe("boolean");
17
+ });
18
+
19
+ it("sets theme to dark", () => {
20
+ const { result } = renderHook(() => useAtlasTheme());
21
+
22
+ act(() => {
23
+ result.current.setTheme("dark");
24
+ });
25
+
26
+ expect(result.current.theme).toBe("dark");
27
+ expect(result.current.isDark).toBe(true);
28
+ });
29
+
30
+ it("toggles between light and dark", () => {
31
+ const { result } = renderHook(() => useAtlasTheme());
32
+
33
+ act(() => {
34
+ result.current.setTheme("light");
35
+ });
36
+ expect(result.current.theme).toBe("light");
37
+ expect(result.current.isDark).toBe(false);
38
+
39
+ act(() => {
40
+ result.current.setTheme("dark");
41
+ });
42
+ expect(result.current.theme).toBe("dark");
43
+ expect(result.current.isDark).toBe(true);
44
+ });
45
+
46
+ it("applyBrandColor sets CSS custom property", () => {
47
+ const { result } = renderHook(() => useAtlasTheme());
48
+
49
+ act(() => {
50
+ result.current.applyBrandColor("oklch(0.759 0.148 167.71)");
51
+ });
52
+
53
+ const value = document.documentElement.style.getPropertyValue("--atlas-brand");
54
+ expect(value).toBe("oklch(0.759 0.148 167.71)");
55
+ });
56
+ });
@@ -0,0 +1,47 @@
1
+ // Provider
2
+ export { AtlasProvider, useAtlasContext } from "./provider";
3
+ export type {
4
+ AtlasProviderProps,
5
+ AtlasContextValue,
6
+ AtlasAuthClient,
7
+ } from "./provider";
8
+
9
+ // Hooks
10
+ export { useAtlasChat } from "./use-atlas-chat";
11
+ export type {
12
+ AtlasChatStatus,
13
+ UseAtlasChatOptions,
14
+ UseAtlasChatReturn,
15
+ } from "./use-atlas-chat";
16
+
17
+ export { useAtlasAuth } from "./use-atlas-auth";
18
+ export type { UseAtlasAuthReturn } from "./use-atlas-auth";
19
+
20
+ export { useAtlasTheme } from "./use-atlas-theme";
21
+ export type { UseAtlasThemeReturn, ThemeMode } from "./use-atlas-theme";
22
+
23
+ export { useAtlasConversations } from "./use-atlas-conversations";
24
+ export type {
25
+ UseAtlasConversationsOptions,
26
+ UseAtlasConversationsReturn,
27
+ } from "./use-atlas-conversations";
28
+
29
+ // Types re-exported for consumers who only import from hooks
30
+ export type {
31
+ AuthMode,
32
+ Conversation,
33
+ Message,
34
+ ConversationWithMessages,
35
+ ChatErrorCode,
36
+ ChatErrorInfo,
37
+ } from "../lib/types";
38
+ export { AUTH_MODES, parseChatError } from "../lib/types";
39
+
40
+ // Tool renderer types
41
+ export type {
42
+ ToolRendererProps,
43
+ ToolRenderers,
44
+ SQLToolResult,
45
+ ExploreToolResult,
46
+ PythonToolResult,
47
+ } from "../lib/tool-renderer-types";
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, type ReactNode } from "react";
4
+ import type { AtlasAuthClient } from "../context";
5
+
6
+ export type { AtlasAuthClient };
7
+
8
+ export interface AtlasProviderProps {
9
+ /** Atlas API server URL (e.g. "https://api.example.com" or "" for same-origin). */
10
+ apiUrl: string;
11
+ /** API key for simple-key auth mode. Sent as Bearer token. Accessible in context by all hooks. */
12
+ apiKey?: string;
13
+ /** Custom auth client for managed auth mode (better-auth compatible). */
14
+ authClient?: AtlasAuthClient;
15
+ children: ReactNode;
16
+ }
17
+
18
+ export interface AtlasContextValue {
19
+ apiUrl: string;
20
+ apiKey: string | undefined;
21
+ authClient: AtlasAuthClient;
22
+ isCrossOrigin: boolean;
23
+ }
24
+
25
+ /** No-op auth client for non-managed auth modes. Warns when auth operations are attempted. */
26
+ const noopAuthClient: AtlasAuthClient = {
27
+ signIn: {
28
+ email: async () => {
29
+ console.warn("[Atlas] signIn called but no authClient was provided to AtlasProvider");
30
+ return { error: { message: "Auth client not configured" } };
31
+ },
32
+ },
33
+ signUp: {
34
+ email: async () => {
35
+ console.warn("[Atlas] signUp called but no authClient was provided to AtlasProvider");
36
+ return { error: { message: "Auth client not configured" } };
37
+ },
38
+ },
39
+ signOut: async () => {
40
+ console.warn("[Atlas] signOut called but no authClient was provided to AtlasProvider");
41
+ },
42
+ useSession: () => ({ data: null, isPending: false }),
43
+ };
44
+
45
+ const AtlasContext = createContext<AtlasContextValue | null>(null);
46
+
47
+ /** Access the AtlasProvider context. Throws if used outside <AtlasProvider>. */
48
+ export function useAtlasContext(): AtlasContextValue {
49
+ const ctx = useContext(AtlasContext);
50
+ if (!ctx) throw new Error("useAtlasContext must be used within <AtlasProvider>");
51
+ return ctx;
52
+ }
53
+
54
+ /**
55
+ * Lightweight provider for headless Atlas hooks.
56
+ *
57
+ * Wraps your app and supplies API URL, auth credentials, and an optional
58
+ * better-auth client to all Atlas hooks. Derives isCrossOrigin from apiUrl
59
+ * to configure credential handling for cross-origin requests.
60
+ */
61
+ export function AtlasProvider({
62
+ apiUrl,
63
+ apiKey,
64
+ authClient = noopAuthClient,
65
+ children,
66
+ }: AtlasProviderProps) {
67
+ const isCrossOrigin =
68
+ typeof window !== "undefined" &&
69
+ apiUrl !== "" &&
70
+ !apiUrl.startsWith(window.location.origin);
71
+
72
+ return (
73
+ <AtlasContext.Provider value={{ apiUrl, apiKey, authClient, isCrossOrigin }}>
74
+ {children}
75
+ </AtlasContext.Provider>
76
+ );
77
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Blocking inline script for layout.tsx — prevents dark-mode flash on load.
3
+ *
4
+ * This file intentionally has NO "use client" directive so it can be imported
5
+ * by server components (layout.tsx). The storage key must stay in sync with
6
+ * use-dark-mode.ts.
7
+ */
8
+
9
+ export const THEME_STORAGE_KEY = "atlas-theme";
10
+
11
+ /**
12
+ * Returns the inline script string for the blocking `<script>` in layout.tsx.
13
+ * Reads atlas-theme from localStorage and sets the `dark` class before first paint.
14
+ */
15
+ export function buildThemeInitScript(): string {
16
+ return `try{var t=localStorage.getItem("${THEME_STORAGE_KEY}");var d=t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches);if(d)document.documentElement.classList.add("dark")}catch(e){}`;
17
+ }
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useAtlasContext } from "./provider";
5
+ import { AUTH_MODES, type AuthMode } from "../lib/types";
6
+
7
+ export interface UseAtlasAuthReturn {
8
+ /** Auth mode detected from the server's /api/health endpoint. `null` while the initial health check is in flight. */
9
+ authMode: AuthMode | null;
10
+ /** Whether the user is authenticated (based on auth mode, API key, or session). */
11
+ isAuthenticated: boolean;
12
+ /** Session data for managed auth mode. */
13
+ session: { user?: { email?: string } } | null;
14
+ /** Whether auth state is still being resolved (health check or managed session loading). */
15
+ isLoading: boolean;
16
+ /** Error from health check or auth operations. `null` when healthy. */
17
+ error: Error | null;
18
+ /** Sign in with email/password (managed auth). */
19
+ login: (email: string, password: string) => Promise<{ error?: string }>;
20
+ /** Sign up with email/password/name (managed auth). */
21
+ signup: (email: string, password: string, name: string) => Promise<{ error?: string }>;
22
+ /** Sign out (managed auth). */
23
+ logout: () => Promise<{ error?: string }>;
24
+ }
25
+
26
+ export function useAtlasAuth(): UseAtlasAuthReturn {
27
+ const { apiUrl, apiKey, authClient, isCrossOrigin } = useAtlasContext();
28
+ const [authMode, setAuthMode] = useState<AuthMode | null>(null);
29
+ const [error, setError] = useState<Error | null>(null);
30
+ const managedSession = authClient.useSession();
31
+
32
+ useEffect(() => {
33
+ let cancelled = false;
34
+
35
+ async function fetchHealth(attempt: number): Promise<void> {
36
+ try {
37
+ const res = await fetch(`${apiUrl}/api/health`, {
38
+ credentials: isCrossOrigin ? "include" : "same-origin",
39
+ });
40
+ if (!res.ok) {
41
+ console.warn(`[Atlas] Health check returned HTTP ${res.status} (attempt ${attempt})`);
42
+ if (attempt < 2 && !cancelled) {
43
+ await new Promise((r) => setTimeout(r, 2000));
44
+ return fetchHealth(attempt + 1);
45
+ }
46
+ if (!cancelled) {
47
+ setError(new Error(`Health check failed with HTTP ${res.status}`));
48
+ setAuthMode("none");
49
+ }
50
+ return;
51
+ }
52
+ const data = await res.json();
53
+ const mode = data?.checks?.auth?.mode;
54
+ if (!cancelled) {
55
+ if (typeof mode === "string" && AUTH_MODES.includes(mode as AuthMode)) {
56
+ setAuthMode(mode as AuthMode);
57
+ } else {
58
+ console.warn("[Atlas] Health check returned no valid auth mode:", data);
59
+ setAuthMode("none");
60
+ }
61
+ }
62
+ } catch (err) {
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ console.warn(`[Atlas] Health check failed (attempt ${attempt}):`, message);
65
+ if (attempt < 2 && !cancelled) {
66
+ await new Promise((r) => setTimeout(r, 2000));
67
+ return fetchHealth(attempt + 1);
68
+ }
69
+ if (!cancelled) {
70
+ setError(err instanceof Error ? err : new Error(message));
71
+ setAuthMode("none");
72
+ }
73
+ }
74
+ }
75
+
76
+ fetchHealth(1);
77
+ return () => { cancelled = true; };
78
+ }, [apiUrl, isCrossOrigin]);
79
+
80
+ const isAuthenticated = (() => {
81
+ if (authMode === null) return false;
82
+ if (authMode === "none") return true;
83
+ if (authMode === "simple-key" || authMode === "byot") return !!apiKey;
84
+ if (authMode === "managed") return !!managedSession.data?.user;
85
+ return false;
86
+ })();
87
+
88
+ const login = async (email: string, password: string) => {
89
+ try {
90
+ const result = await authClient.signIn.email({ email, password });
91
+ return { error: result.error?.message };
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : "Login failed";
94
+ console.warn("[Atlas] login error:", message);
95
+ return { error: message };
96
+ }
97
+ };
98
+
99
+ const signup = async (email: string, password: string, name: string) => {
100
+ try {
101
+ const result = await authClient.signUp.email({ email, password, name });
102
+ return { error: result.error?.message };
103
+ } catch (err) {
104
+ const message = err instanceof Error ? err.message : "Signup failed";
105
+ console.warn("[Atlas] signup error:", message);
106
+ return { error: message };
107
+ }
108
+ };
109
+
110
+ const logout = async () => {
111
+ try {
112
+ await authClient.signOut();
113
+ return {};
114
+ } catch (err) {
115
+ const message = err instanceof Error ? err.message : "Logout failed";
116
+ console.warn("[Atlas] logout error:", message);
117
+ return { error: message };
118
+ }
119
+ };
120
+
121
+ return {
122
+ authMode,
123
+ isAuthenticated,
124
+ session: managedSession.data ?? null,
125
+ isLoading: authMode === null || (authMode === "managed" && !!managedSession.isPending),
126
+ error,
127
+ login,
128
+ signup,
129
+ logout,
130
+ };
131
+ }