@cortexmemory/cli 0.27.1 → 0.27.3

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 (46) hide show
  1. package/dist/commands/convex.js +1 -1
  2. package/dist/commands/convex.js.map +1 -1
  3. package/dist/commands/deploy.d.ts +1 -1
  4. package/dist/commands/deploy.d.ts.map +1 -1
  5. package/dist/commands/deploy.js +771 -144
  6. package/dist/commands/deploy.js.map +1 -1
  7. package/dist/commands/dev.d.ts.map +1 -1
  8. package/dist/commands/dev.js +89 -26
  9. package/dist/commands/dev.js.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/utils/app-template-sync.d.ts +95 -0
  12. package/dist/utils/app-template-sync.d.ts.map +1 -0
  13. package/dist/utils/app-template-sync.js +425 -0
  14. package/dist/utils/app-template-sync.js.map +1 -0
  15. package/dist/utils/deployment-selector.d.ts +21 -0
  16. package/dist/utils/deployment-selector.d.ts.map +1 -1
  17. package/dist/utils/deployment-selector.js +32 -0
  18. package/dist/utils/deployment-selector.js.map +1 -1
  19. package/dist/utils/init/graph-setup.d.ts.map +1 -1
  20. package/dist/utils/init/graph-setup.js +13 -2
  21. package/dist/utils/init/graph-setup.js.map +1 -1
  22. package/package.json +1 -1
  23. package/templates/vercel-ai-quickstart/app/api/auth/check/route.ts +30 -0
  24. package/templates/vercel-ai-quickstart/app/api/auth/login/route.ts +83 -0
  25. package/templates/vercel-ai-quickstart/app/api/auth/register/route.ts +94 -0
  26. package/templates/vercel-ai-quickstart/app/api/auth/setup/route.ts +59 -0
  27. package/templates/vercel-ai-quickstart/app/api/chat/route.ts +83 -2
  28. package/templates/vercel-ai-quickstart/app/api/conversations/route.ts +179 -0
  29. package/templates/vercel-ai-quickstart/app/globals.css +161 -0
  30. package/templates/vercel-ai-quickstart/app/page.tsx +93 -8
  31. package/templates/vercel-ai-quickstart/components/AdminSetup.tsx +139 -0
  32. package/templates/vercel-ai-quickstart/components/AuthProvider.tsx +283 -0
  33. package/templates/vercel-ai-quickstart/components/ChatHistorySidebar.tsx +323 -0
  34. package/templates/vercel-ai-quickstart/components/ChatInterface.tsx +113 -16
  35. package/templates/vercel-ai-quickstart/components/LoginScreen.tsx +202 -0
  36. package/templates/vercel-ai-quickstart/jest.config.js +45 -0
  37. package/templates/vercel-ai-quickstart/lib/cortex.ts +27 -0
  38. package/templates/vercel-ai-quickstart/lib/password.ts +120 -0
  39. package/templates/vercel-ai-quickstart/next.config.js +20 -0
  40. package/templates/vercel-ai-quickstart/package.json +7 -2
  41. package/templates/vercel-ai-quickstart/tests/helpers/mock-cortex.ts +263 -0
  42. package/templates/vercel-ai-quickstart/tests/helpers/setup.ts +48 -0
  43. package/templates/vercel-ai-quickstart/tests/integration/auth.test.ts +455 -0
  44. package/templates/vercel-ai-quickstart/tests/integration/conversations.test.ts +461 -0
  45. package/templates/vercel-ai-quickstart/tests/unit/password.test.ts +228 -0
  46. package/templates/vercel-ai-quickstart/tsconfig.json +1 -1
@@ -0,0 +1,283 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useState,
7
+ useEffect,
8
+ useCallback,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13
+ // Types
14
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
15
+
16
+ export interface User {
17
+ id: string;
18
+ displayName: string;
19
+ }
20
+
21
+ export interface AuthState {
22
+ isLoading: boolean;
23
+ isAdminSetup: boolean | null;
24
+ isAuthenticated: boolean;
25
+ user: User | null;
26
+ error: string | null;
27
+ }
28
+
29
+ export interface AuthContextValue extends AuthState {
30
+ setupAdmin: (password: string) => Promise<boolean>;
31
+ login: (username: string, password: string) => Promise<boolean>;
32
+ register: (
33
+ username: string,
34
+ password: string,
35
+ displayName?: string
36
+ ) => Promise<boolean>;
37
+ logout: () => void;
38
+ clearError: () => void;
39
+ }
40
+
41
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42
+ // Context
43
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
44
+
45
+ const AuthContext = createContext<AuthContextValue | null>(null);
46
+
47
+ const STORAGE_KEY = "cortex-quickstart-auth";
48
+
49
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
50
+ // Provider
51
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
52
+
53
+ export function AuthProvider({ children }: { children: ReactNode }) {
54
+ const [state, setState] = useState<AuthState>({
55
+ isLoading: true,
56
+ isAdminSetup: null,
57
+ isAuthenticated: false,
58
+ user: null,
59
+ error: null,
60
+ });
61
+
62
+ // Check admin setup status and restore session on mount
63
+ useEffect(() => {
64
+ const init = async () => {
65
+ try {
66
+ // Check if admin is set up
67
+ const checkResponse = await fetch("/api/auth/check");
68
+ const checkData = await checkResponse.json();
69
+
70
+ // Try to restore session from localStorage
71
+ let user: User | null = null;
72
+ let sessionToken: string | null = null;
73
+
74
+ if (typeof window !== "undefined") {
75
+ const stored = localStorage.getItem(STORAGE_KEY);
76
+ if (stored) {
77
+ try {
78
+ const parsed = JSON.parse(stored);
79
+ user = parsed.user;
80
+ sessionToken = parsed.sessionToken;
81
+ } catch {
82
+ localStorage.removeItem(STORAGE_KEY);
83
+ }
84
+ }
85
+ }
86
+
87
+ setState({
88
+ isLoading: false,
89
+ isAdminSetup: checkData.isSetup,
90
+ isAuthenticated: !!user && !!sessionToken,
91
+ user,
92
+ error: null,
93
+ });
94
+ } catch (error) {
95
+ console.error("Auth init error:", error);
96
+ setState((prev) => ({
97
+ ...prev,
98
+ isLoading: false,
99
+ error: "Failed to initialize authentication",
100
+ }));
101
+ }
102
+ };
103
+
104
+ init();
105
+ }, []);
106
+
107
+ // Setup admin password
108
+ const setupAdmin = useCallback(async (password: string): Promise<boolean> => {
109
+ setState((prev) => ({ ...prev, error: null }));
110
+
111
+ try {
112
+ const response = await fetch("/api/auth/setup", {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ password }),
116
+ });
117
+
118
+ const data = await response.json();
119
+
120
+ if (!response.ok) {
121
+ setState((prev) => ({ ...prev, error: data.error }));
122
+ return false;
123
+ }
124
+
125
+ setState((prev) => ({
126
+ ...prev,
127
+ isAdminSetup: true,
128
+ }));
129
+
130
+ return true;
131
+ } catch (error) {
132
+ console.error("Setup error:", error);
133
+ setState((prev) => ({
134
+ ...prev,
135
+ error: "Failed to setup admin password",
136
+ }));
137
+ return false;
138
+ }
139
+ }, []);
140
+
141
+ // Login
142
+ const login = useCallback(
143
+ async (username: string, password: string): Promise<boolean> => {
144
+ setState((prev) => ({ ...prev, error: null }));
145
+
146
+ try {
147
+ const response = await fetch("/api/auth/login", {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/json" },
150
+ body: JSON.stringify({ username, password }),
151
+ });
152
+
153
+ const data = await response.json();
154
+
155
+ if (!response.ok) {
156
+ setState((prev) => ({ ...prev, error: data.error }));
157
+ return false;
158
+ }
159
+
160
+ // Store session
161
+ if (typeof window !== "undefined") {
162
+ localStorage.setItem(
163
+ STORAGE_KEY,
164
+ JSON.stringify({
165
+ user: data.user,
166
+ sessionToken: data.sessionToken,
167
+ })
168
+ );
169
+ }
170
+
171
+ setState((prev) => ({
172
+ ...prev,
173
+ isAuthenticated: true,
174
+ user: data.user,
175
+ }));
176
+
177
+ return true;
178
+ } catch (error) {
179
+ console.error("Login error:", error);
180
+ setState((prev) => ({
181
+ ...prev,
182
+ error: "Failed to login",
183
+ }));
184
+ return false;
185
+ }
186
+ },
187
+ []
188
+ );
189
+
190
+ // Register
191
+ const register = useCallback(
192
+ async (
193
+ username: string,
194
+ password: string,
195
+ displayName?: string
196
+ ): Promise<boolean> => {
197
+ setState((prev) => ({ ...prev, error: null }));
198
+
199
+ try {
200
+ const response = await fetch("/api/auth/register", {
201
+ method: "POST",
202
+ headers: { "Content-Type": "application/json" },
203
+ body: JSON.stringify({ username, password, displayName }),
204
+ });
205
+
206
+ const data = await response.json();
207
+
208
+ if (!response.ok) {
209
+ setState((prev) => ({ ...prev, error: data.error }));
210
+ return false;
211
+ }
212
+
213
+ // Store session
214
+ if (typeof window !== "undefined") {
215
+ localStorage.setItem(
216
+ STORAGE_KEY,
217
+ JSON.stringify({
218
+ user: data.user,
219
+ sessionToken: data.sessionToken,
220
+ })
221
+ );
222
+ }
223
+
224
+ setState((prev) => ({
225
+ ...prev,
226
+ isAuthenticated: true,
227
+ user: data.user,
228
+ }));
229
+
230
+ return true;
231
+ } catch (error) {
232
+ console.error("Register error:", error);
233
+ setState((prev) => ({
234
+ ...prev,
235
+ error: "Failed to register",
236
+ }));
237
+ return false;
238
+ }
239
+ },
240
+ []
241
+ );
242
+
243
+ // Logout
244
+ const logout = useCallback(() => {
245
+ if (typeof window !== "undefined") {
246
+ localStorage.removeItem(STORAGE_KEY);
247
+ }
248
+
249
+ setState((prev) => ({
250
+ ...prev,
251
+ isAuthenticated: false,
252
+ user: null,
253
+ }));
254
+ }, []);
255
+
256
+ // Clear error
257
+ const clearError = useCallback(() => {
258
+ setState((prev) => ({ ...prev, error: null }));
259
+ }, []);
260
+
261
+ const value: AuthContextValue = {
262
+ ...state,
263
+ setupAdmin,
264
+ login,
265
+ register,
266
+ logout,
267
+ clearError,
268
+ };
269
+
270
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
271
+ }
272
+
273
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
274
+ // Hook
275
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
276
+
277
+ export function useAuth(): AuthContextValue {
278
+ const context = useContext(AuthContext);
279
+ if (!context) {
280
+ throw new Error("useAuth must be used within an AuthProvider");
281
+ }
282
+ return context;
283
+ }
@@ -0,0 +1,323 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { useAuth } from "./AuthProvider";
5
+
6
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7
+ // Types
8
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9
+
10
+ interface Conversation {
11
+ id: string;
12
+ title: string;
13
+ createdAt: number;
14
+ updatedAt: number;
15
+ messageCount: number;
16
+ }
17
+
18
+ interface ChatHistorySidebarProps {
19
+ memorySpaceId: string;
20
+ currentConversationId: string | null;
21
+ onSelectConversation: (conversationId: string) => void;
22
+ onNewChat: () => void;
23
+ }
24
+
25
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26
+ // Helpers
27
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28
+
29
+ function groupByDate(conversations: Conversation[]): Record<string, Conversation[]> {
30
+ const now = new Date();
31
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
32
+ const yesterday = today - 86400000;
33
+ const weekAgo = today - 7 * 86400000;
34
+
35
+ const groups: Record<string, Conversation[]> = {
36
+ Today: [],
37
+ Yesterday: [],
38
+ "Last 7 Days": [],
39
+ Older: [],
40
+ };
41
+
42
+ for (const conv of conversations) {
43
+ const convDate = new Date(conv.updatedAt);
44
+ const convDay = new Date(
45
+ convDate.getFullYear(),
46
+ convDate.getMonth(),
47
+ convDate.getDate()
48
+ ).getTime();
49
+
50
+ if (convDay >= today) {
51
+ groups.Today.push(conv);
52
+ } else if (convDay >= yesterday) {
53
+ groups.Yesterday.push(conv);
54
+ } else if (convDay >= weekAgo) {
55
+ groups["Last 7 Days"].push(conv);
56
+ } else {
57
+ groups.Older.push(conv);
58
+ }
59
+ }
60
+
61
+ return groups;
62
+ }
63
+
64
+ function formatTime(timestamp: number): string {
65
+ return new Date(timestamp).toLocaleTimeString("en-US", {
66
+ hour: "numeric",
67
+ minute: "2-digit",
68
+ });
69
+ }
70
+
71
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
72
+ // Component
73
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
74
+
75
+ export function ChatHistorySidebar({
76
+ memorySpaceId,
77
+ currentConversationId,
78
+ onSelectConversation,
79
+ onNewChat,
80
+ }: ChatHistorySidebarProps) {
81
+ const { user, logout } = useAuth();
82
+ const [conversations, setConversations] = useState<Conversation[]>([]);
83
+ const [isLoading, setIsLoading] = useState(true);
84
+ const [deletingId, setDeletingId] = useState<string | null>(null);
85
+
86
+ // Fetch conversations
87
+ const fetchConversations = useCallback(async () => {
88
+ if (!user) return;
89
+
90
+ try {
91
+ const response = await fetch(
92
+ `/api/conversations?userId=${encodeURIComponent(user.id)}&memorySpaceId=${encodeURIComponent(memorySpaceId)}`
93
+ );
94
+ const data = await response.json();
95
+ if (data.conversations) {
96
+ setConversations(data.conversations);
97
+ }
98
+ } catch (error) {
99
+ console.error("Failed to fetch conversations:", error);
100
+ } finally {
101
+ setIsLoading(false);
102
+ }
103
+ }, [user, memorySpaceId]);
104
+
105
+ // Fetch on mount and when dependencies change
106
+ useEffect(() => {
107
+ fetchConversations();
108
+ }, [fetchConversations]);
109
+
110
+ // Refresh conversations periodically (every 10 seconds)
111
+ useEffect(() => {
112
+ const interval = setInterval(fetchConversations, 10000);
113
+ return () => clearInterval(interval);
114
+ }, [fetchConversations]);
115
+
116
+ // Delete conversation
117
+ const handleDelete = async (e: React.MouseEvent, conversationId: string) => {
118
+ e.stopPropagation();
119
+ if (deletingId) return;
120
+
121
+ setDeletingId(conversationId);
122
+
123
+ try {
124
+ const response = await fetch(`/api/conversations?conversationId=${encodeURIComponent(conversationId)}`, {
125
+ method: "DELETE",
126
+ });
127
+
128
+ if (!response.ok) {
129
+ const data = await response.json().catch(() => ({}));
130
+ throw new Error(data.error || `Delete failed with status ${response.status}`);
131
+ }
132
+
133
+ // Remove from local state only after successful deletion
134
+ setConversations((prev) => prev.filter((c) => c.id !== conversationId));
135
+
136
+ // If deleted conversation was selected, trigger new chat
137
+ if (conversationId === currentConversationId) {
138
+ onNewChat();
139
+ }
140
+ } catch (error) {
141
+ console.error("Failed to delete conversation:", error);
142
+ } finally {
143
+ setDeletingId(null);
144
+ }
145
+ };
146
+
147
+ const groupedConversations = groupByDate(conversations);
148
+ const groups = ["Today", "Yesterday", "Last 7 Days", "Older"];
149
+
150
+ return (
151
+ <div className="w-64 h-full flex flex-col bg-black/40 border-r border-white/10">
152
+ {/* Header */}
153
+ <div className="p-4 border-b border-white/10">
154
+ <div className="flex items-center gap-2 mb-4">
155
+ <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cortex-500 to-cortex-700 flex items-center justify-center">
156
+ <span className="text-sm">🧠</span>
157
+ </div>
158
+ <span className="font-semibold text-sm">Cortex Demo</span>
159
+ </div>
160
+
161
+ <button
162
+ onClick={onNewChat}
163
+ className="w-full py-2.5 px-4 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-sm font-medium transition-colors flex items-center gap-2"
164
+ >
165
+ <svg
166
+ className="w-4 h-4"
167
+ fill="none"
168
+ viewBox="0 0 24 24"
169
+ stroke="currentColor"
170
+ >
171
+ <path
172
+ strokeLinecap="round"
173
+ strokeLinejoin="round"
174
+ strokeWidth={2}
175
+ d="M12 4v16m8-8H4"
176
+ />
177
+ </svg>
178
+ New Chat
179
+ </button>
180
+ </div>
181
+
182
+ {/* Conversation List */}
183
+ <div className="flex-1 overflow-y-auto p-2">
184
+ {isLoading ? (
185
+ <div className="flex items-center justify-center py-8">
186
+ <svg
187
+ className="animate-spin h-5 w-5 text-gray-400"
188
+ xmlns="http://www.w3.org/2000/svg"
189
+ fill="none"
190
+ viewBox="0 0 24 24"
191
+ >
192
+ <circle
193
+ className="opacity-25"
194
+ cx="12"
195
+ cy="12"
196
+ r="10"
197
+ stroke="currentColor"
198
+ strokeWidth="4"
199
+ />
200
+ <path
201
+ className="opacity-75"
202
+ fill="currentColor"
203
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
204
+ />
205
+ </svg>
206
+ </div>
207
+ ) : conversations.length === 0 ? (
208
+ <div className="text-center py-8 text-gray-500 text-sm">
209
+ <p>No conversations yet</p>
210
+ <p className="mt-1 text-xs">Start a new chat to begin</p>
211
+ </div>
212
+ ) : (
213
+ <div className="space-y-4">
214
+ {groups.map((group) => {
215
+ const groupConvos = groupedConversations[group];
216
+ if (groupConvos.length === 0) return null;
217
+
218
+ return (
219
+ <div key={group}>
220
+ <div className="px-2 py-1 text-xs font-medium text-gray-500 uppercase tracking-wider">
221
+ {group}
222
+ </div>
223
+ <div className="space-y-1">
224
+ {groupConvos.map((conv) => (
225
+ <div
226
+ key={conv.id}
227
+ onClick={() => onSelectConversation(conv.id)}
228
+ className={`group relative px-3 py-2.5 rounded-xl cursor-pointer transition-colors ${
229
+ conv.id === currentConversationId
230
+ ? "bg-cortex-600/20 text-white"
231
+ : "hover:bg-white/5 text-gray-300"
232
+ }`}
233
+ >
234
+ <div className="pr-6">
235
+ <div className="text-sm font-medium truncate">
236
+ {conv.title}
237
+ </div>
238
+ <div className="text-xs text-gray-500 mt-0.5">
239
+ {formatTime(conv.updatedAt)}
240
+ {conv.messageCount > 0 && (
241
+ <span className="ml-2">
242
+ {conv.messageCount} message
243
+ {conv.messageCount !== 1 ? "s" : ""}
244
+ </span>
245
+ )}
246
+ </div>
247
+ </div>
248
+
249
+ {/* Delete button */}
250
+ <button
251
+ onClick={(e) => handleDelete(e, conv.id)}
252
+ disabled={deletingId === conv.id}
253
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1 opacity-0 group-hover:opacity-100 hover:bg-white/10 rounded transition-all"
254
+ title="Delete conversation"
255
+ >
256
+ {deletingId === conv.id ? (
257
+ <svg
258
+ className="w-4 h-4 animate-spin text-gray-400"
259
+ fill="none"
260
+ viewBox="0 0 24 24"
261
+ >
262
+ <circle
263
+ className="opacity-25"
264
+ cx="12"
265
+ cy="12"
266
+ r="10"
267
+ stroke="currentColor"
268
+ strokeWidth="4"
269
+ />
270
+ <path
271
+ className="opacity-75"
272
+ fill="currentColor"
273
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
274
+ />
275
+ </svg>
276
+ ) : (
277
+ <svg
278
+ className="w-4 h-4 text-gray-400 hover:text-red-400"
279
+ fill="none"
280
+ viewBox="0 0 24 24"
281
+ stroke="currentColor"
282
+ >
283
+ <path
284
+ strokeLinecap="round"
285
+ strokeLinejoin="round"
286
+ strokeWidth={2}
287
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
288
+ />
289
+ </svg>
290
+ )}
291
+ </button>
292
+ </div>
293
+ ))}
294
+ </div>
295
+ </div>
296
+ );
297
+ })}
298
+ </div>
299
+ )}
300
+ </div>
301
+
302
+ {/* User Section */}
303
+ <div className="p-4 border-t border-white/10">
304
+ <div className="flex items-center gap-3">
305
+ <div className="w-9 h-9 rounded-full bg-gradient-to-br from-cortex-500 to-cortex-700 flex items-center justify-center text-sm font-medium">
306
+ {user?.displayName?.charAt(0).toUpperCase() || "U"}
307
+ </div>
308
+ <div className="flex-1 min-w-0">
309
+ <div className="text-sm font-medium truncate">
310
+ {user?.displayName || user?.id}
311
+ </div>
312
+ <button
313
+ onClick={logout}
314
+ className="text-xs text-gray-500 hover:text-white transition-colors"
315
+ >
316
+ Sign out
317
+ </button>
318
+ </div>
319
+ </div>
320
+ </div>
321
+ </div>
322
+ );
323
+ }