Merge branch 'main' into codex/safe-local-testing-guide

This commit is contained in:
cosimoastrada 2026-05-08 23:05:03 +08:00 committed by GitHub
commit 1f191fea59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3061 additions and 862 deletions

View file

@ -3,7 +3,7 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "frontend-app",
"name": "mike",
"dependencies": {
"@aws-sdk/client-s3": "^3.1025.0",
"@aws-sdk/s3-request-presigner": "^3.1025.0",

View file

@ -7,7 +7,6 @@
"": {
"name": "mike",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@aws-sdk/client-s3": "^3.1025.0",
"@aws-sdk/s3-request-presigner": "^3.1025.0",

View file

@ -1,7 +1,6 @@
{
"name": "mike",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"private": true,
"scripts": {
"dev": "next dev",
@ -69,5 +68,6 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"wrangler": "^4.51.0"
}
},
"license": "AGPL-3.0-only"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View file

@ -74,18 +74,20 @@ export default function ModelsAndApiKeysPage() {
<ApiKeyField
label="Anthropic (Claude) API Key"
placeholder="sk-ant-…"
initialValue={profile?.claudeApiKey ?? ""}
hasSavedKey={!!profile?.claudeApiKey}
onSave={(value) =>
updateApiKey("claude", value.trim() || null)
}
onRemove={() => updateApiKey("claude", null)}
/>
<ApiKeyField
label="Google (Gemini) API Key"
placeholder="AI…"
initialValue={profile?.geminiApiKey ?? ""}
hasSavedKey={!!profile?.geminiApiKey}
onSave={(value) =>
updateApiKey("gemini", value.trim() || null)
}
onRemove={() => updateApiKey("gemini", null)}
/>
</div>
</div>
@ -183,30 +185,33 @@ function TabularModelDropdown({
function ApiKeyField({
label,
placeholder,
initialValue,
hasSavedKey,
onSave,
onRemove,
}: {
label: string;
placeholder: string;
initialValue: string;
hasSavedKey: boolean;
onSave: (value: string) => Promise<boolean>;
onRemove: () => Promise<boolean>;
}) {
const [value, setValue] = useState(initialValue);
const [value, setValue] = useState("");
const [reveal, setReveal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
setValue("");
}, [hasSavedKey]);
const dirty = value !== initialValue;
const dirty = value.trim().length > 0;
const handleSave = async () => {
setIsSaving(true);
const ok = await onSave(value);
setIsSaving(false);
if (ok) {
setValue("");
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else {
@ -214,16 +219,28 @@ function ApiKeyField({
}
};
const handleRemove = async () => {
setIsSaving(true);
const ok = await onRemove();
setIsSaving(false);
if (!ok) alert(`Failed to remove ${label}.`);
};
return (
<div>
<label className="text-sm text-gray-600 block mb-2">{label}</label>
{hasSavedKey && (
<p className="text-xs text-gray-500 mb-2">
A key is saved. Paste a new key to replace it.
</p>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={reveal ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
placeholder={hasSavedKey ? "Saved key hidden" : placeholder}
className="pr-10"
autoComplete="off"
spellCheck={false}
@ -257,6 +274,16 @@ function ApiKeyField({
"Save"
)}
</Button>
{hasSavedKey && (
<Button
type="button"
variant="outline"
onClick={handleRemove}
disabled={isSaving}
>
Remove
</Button>
)}
</div>
</div>
);

View file

@ -0,0 +1,13 @@
"use client";
import { use } from "react";
import { ProjectPage } from "@/app/components/projects/ProjectPage";
interface Props {
params: Promise<{ id: string }>;
}
export default function ProjectAssistantPage({ params }: Props) {
const { id } = use(params);
return <ProjectPage projectId={id} initialTab="assistant" />;
}

View file

@ -0,0 +1,13 @@
"use client";
import { use } from "react";
import { ProjectPage } from "@/app/components/projects/ProjectPage";
interface Props {
params: Promise<{ id: string }>;
}
export default function ProjectTabularReviewsPage({ params }: Props) {
const { id } = use(params);
return <ProjectPage projectId={id} initialTab="reviews" />;
}

View file

@ -67,10 +67,12 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
} | null>(null);
const [model, setModel] = useSelectedModel();
const { profile } = useUserProfile();
const apiKeys = {
claudeApiKey: profile?.claudeApiKey ?? null,
geminiApiKey: profile?.geminiApiKey ?? null,
};
const apiKeys = profile
? {
claudeApiKey: profile?.claudeApiKey ?? null,
geminiApiKey: profile?.geminiApiKey ?? null,
}
: undefined;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [docSelectorOpen, setDocSelectorOpen] = useState(false);
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
@ -116,7 +118,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
const handleSubmit = () => {
const query = value.trim();
if (!query || isLoading) return;
if (!isModelAvailable(model, apiKeys)) {
if (apiKeys && !isModelAvailable(model, apiKeys)) {
setApiKeyModalProvider(getModelProvider(model));
return;
}

View file

@ -66,6 +66,7 @@ import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
interface Props {
projectId: string;
initialTab?: Tab;
}
type Tab = "documents" | "assistant" | "reviews";
@ -271,7 +272,7 @@ function DocVersionHistory({
);
}
export function ProjectPage({ projectId }: Props) {
export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const [project, setProject] = useState<MikeProject | null>(null);
const [folders, setFolders] = useState<MikeFolder[]>([]);
const [chats, setChats] = useState<MikeChat[]>([]);
@ -282,7 +283,7 @@ export function ProjectPage({ projectId }: Props) {
const tab: Tab =
tabParam === "assistant" || tabParam === "reviews"
? tabParam
: "documents";
: initialTab;
const [addDocsOpen, setAddDocsOpen] = useState(false);
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);

View file

@ -447,6 +447,7 @@ function TRChatInput({
model,
onModelChange,
apiKeys,
onHeightChange,
}: {
isLoading: boolean;
onSubmit: (value: string) => void;
@ -454,10 +455,42 @@ function TRChatInput({
model: string;
onModelChange: (id: string) => void;
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null };
onHeightChange: (height: number) => void;
}) {
const [value, setValue] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const notify = () => {
onHeightChange(root.getBoundingClientRect().height);
};
notify();
const observer = new ResizeObserver(notify);
observer.observe(root);
window.addEventListener("resize", notify);
return () => {
observer.disconnect();
window.removeEventListener("resize", notify);
};
}, [onHeightChange]);
function resizeTextarea(el: HTMLTextAreaElement) {
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 192)}px`;
el.style.overflowY = el.scrollHeight > 192 ? "auto" : "hidden";
}
function resetTextarea() {
if (!textareaRef.current) return;
textareaRef.current.style.height = "auto";
textareaRef.current.style.overflowY = "hidden";
}
function handleAction() {
if (isLoading) {
onCancel();
@ -466,13 +499,16 @@ function TRChatInput({
const trimmed = value.trim();
if (!trimmed) return;
setValue("");
if (textareaRef.current) textareaRef.current.style.height = "auto";
resetTextarea();
onSubmit(trimmed);
}
return (
<div className="absolute bottom-0 left-0 right-0 mx-4 pb-4 bg-white">
<div className="border border-gray-300 rounded-xl bg-white pt-1.5 pb-1.5 flex flex-col gap-1">
<div
ref={rootRef}
className="absolute bottom-0 left-0 right-0 px-4 pb-4 bg-white"
>
<div className="border border-gray-300 rounded-xl bg-white pt-2 pb-1.5 flex flex-col gap-1">
<textarea
ref={textareaRef}
rows={1}
@ -480,8 +516,7 @@ function TRChatInput({
value={value}
onChange={(e) => {
setValue(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
resizeTextarea(e.target);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
@ -489,7 +524,7 @@ function TRChatInput({
handleAction();
}
}}
className="flex-1 resize-none text-sm bg-transparent outline-none placeholder:text-gray-400 leading-6 max-h-48 overflow-y-auto border-0 p-0 pl-3 pr-2 pt-1"
className="w-full resize-none text-sm bg-transparent outline-none placeholder:text-gray-400 leading-6 max-h-48 overflow-hidden border-0 p-0 pl-3 pr-2 pt-0.5"
/>
<div className="flex items-center justify-between pl-1 pr-2">
<ModelToggle
@ -629,6 +664,7 @@ export function TRChatPanel({
const [messagesVisible, setMessagesVisible] = useState(false);
const [panelWidth, setPanelWidth] = useState(380);
const [isResizing, setIsResizing] = useState(false);
const [inputHeight, setInputHeight] = useState(96);
useEffect(() => {
if (!isResizing) return;
@ -1392,7 +1428,8 @@ export function TRChatPanel({
{/* Messages */}
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto px-4 pt-4 pb-[96px] flex flex-col"
className="flex-1 overflow-y-auto px-4 pt-4 flex flex-col"
style={{ paddingBottom: Math.ceil(inputHeight + 16) }}
>
{messages.length === 0 && !isLoadingMessages && (
<div className="flex flex-1 flex-col items-center justify-center gap-2">
@ -1458,6 +1495,7 @@ export function TRChatPanel({
updateModelPreference("tabularModel", id)
}
apiKeys={apiKeys}
onHeightChange={setInputHeight}
/>
<ApiKeyMissingModal

View file

@ -17,9 +17,9 @@ export default function GlobalError({
<title>Something went wrong Mike</title>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=EB+Garamond:wght@400;500&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: #ffffff;

View file

@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { streamChat, streamProjectChat } from "@/app/lib/mikeApi";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
@ -70,6 +70,14 @@ export function useAssistantChat({
const events = last.events ?? [];
const idx = findLastContentIndex(events);
if (idx < 0) return prev;
const current = events[idx];
if (
current.type === "content" &&
current.text === text &&
!!current.isStreaming === !!isStreaming
) {
return prev;
}
const newEvents = [...events];
newEvents[idx] = isStreaming
? { type: "content", text, isStreaming: true }
@ -149,7 +157,10 @@ export function useAssistantChat({
dripIntervalRef.current = setInterval(() => {
const target = dripTargetRef.current;
const displayLen = dripDisplayLenRef.current;
if (displayLen >= target.length) return;
if (displayLen >= target.length) {
stopDrip();
return;
}
const newLen = Math.min(
displayLen + DRIP_CHARS_PER_TICK,
@ -173,9 +184,17 @@ export function useAssistantChat({
setMessages((prev) =>
updateLastContentEvent(prev, visibleText, true),
);
if (newLen >= target.length) {
stopDrip();
}
}, 16);
};
useEffect(() => {
return () => stopDrip();
}, []);
const cancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@ -822,8 +841,8 @@ export function useAssistantChat({
}
return streamedChatId || null;
} catch (error: any) {
if (error.name === "AbortError") {
} catch (error: unknown) {
if (error instanceof Error && error.name === "AbortError") {
flushDrip();
setMessages((prev) => {
const last = prev[prev.length - 1];
@ -873,7 +892,7 @@ export function useAssistantChat({
} else {
stopDrip();
const errorMessage =
typeof error?.message === "string" && error.message
error instanceof Error && error.message
? error.message
: "Sorry, something went wrong.";
setMessages((prev) => {

View file

@ -15,6 +15,7 @@ const ebGaramond = EB_Garamond({
});
export const metadata: Metadata = {
metadataBase: new URL("https://app.mikeoss.com"),
title: "Mike - AI Legal Platform",
description:
"AI-powered legal document analysis and contract review platform.",
@ -25,6 +26,29 @@ export const metadata: Metadata = {
],
apple: "/apple-touch-icon.png",
},
openGraph: {
type: "website",
url: "https://app.mikeoss.com",
siteName: "Mike",
title: "Mike - AI Legal Platform",
description:
"AI-powered legal document analysis and contract review platform.",
images: [
{
url: "/link-image.jpg",
width: 1200,
height: 651,
alt: "Mike",
},
],
},
twitter: {
card: "summary_large_image",
title: "Mike - AI Legal Platform",
description:
"AI-powered legal document analysis and contract review platform.",
images: ["/link-image.jpg"],
},
};
export default function RootLayout({

View file

@ -97,6 +97,53 @@ export async function deleteAccount(): Promise<void> {
return apiRequest<void>("/user/account", { method: "DELETE" });
}
export interface UserProfile {
displayName: string | null;
organisation: string | null;
messageCreditsUsed: number;
creditsResetDate: string;
creditsRemaining: number;
tier: string;
tabularModel: string;
apiKeyStatus: ApiKeyStatus;
}
export async function getUserProfile(): Promise<UserProfile> {
return apiRequest<UserProfile>("/user/profile");
}
export async function updateUserProfile(payload: {
displayName?: string | null;
organisation?: string | null;
tabularModel?: string;
}): Promise<UserProfile> {
return apiRequest<UserProfile>("/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export type ApiKeyStatus = {
claude: boolean;
gemini: boolean;
};
export async function getApiKeyStatus(): Promise<ApiKeyStatus> {
return apiRequest<ApiKeyStatus>("/user/api-keys");
}
export async function saveApiKey(
provider: keyof ApiKeyStatus,
apiKey: string | null,
): Promise<ApiKeyStatus> {
return apiRequest<ApiKeyStatus>(`/user/api-keys/${provider}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey }),
});
}
export async function getProject(projectId: string): Promise<MikeProject> {
return apiRequest<MikeProject>(`/projects/${projectId}`);
}
@ -230,9 +277,7 @@ export interface MikeDocumentVersion {
display_name: string | null;
}
export async function listDocumentVersions(
documentId: string,
): Promise<{
export async function listDocumentVersions(documentId: string): Promise<{
current_version_id: string | null;
versions: MikeDocumentVersion[];
}> {
@ -321,9 +366,7 @@ export async function getDocumentUrl(
documentId: string,
versionId?: string | null,
): Promise<{ url: string; filename: string; version_id: string | null }> {
const qs = versionId
? `?version_id=${encodeURIComponent(versionId)}`
: "";
const qs = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
return apiRequest(`/single-documents/${documentId}/url${qs}`);
}
@ -483,9 +526,7 @@ export async function streamProjectChat(payload: {
export async function listTabularReviews(
projectId?: string,
): Promise<TabularReview[]> {
const qs = projectId
? `?project_id=${encodeURIComponent(projectId)}`
: "";
const qs = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
return apiRequest<TabularReview[]>(`/tabular-review${qs}`);
}
@ -794,9 +835,7 @@ export async function shareWorkflow(
});
}
export async function listWorkflowShares(
workflowId: string,
): Promise<
export async function listWorkflowShares(workflowId: string): Promise<
{
id: string;
shared_with_email: string;

View file

@ -50,7 +50,7 @@ export default function LoginPage() {
</div>
<div className="w-full max-w-md">
{/* Login Form */}
<div className="bg-white border border-gray-200 rounded-2xl p-8">
<div className="bg-white border border-gray-200 rounded-2xl p-8 mb-4">
<div className="flex justify-between items-center mb-6">
<h2 className="text-left text-2xl font-serif">
Log In
@ -119,6 +119,12 @@ export default function LoginPage() {
</Button>
</form>
</div>
<p className="text-center text-xs text-gray-500 leading-relaxed px-2">
Mike hosted on MikeOSS.com is currently a demo service.
Please do not upload, submit, or store sensitive,
confidential, privileged, client, or personally
identifiable documents.
</p>
</div>
</div>
);

View file

@ -0,0 +1,160 @@
"use client";
export default function PrivacyPage() {
return (
<main className="w-full px-6 py-6 md:py-10">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-medium font-eb-garamond mb-8">
Privacy Policy
</h1>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">
1. Introduction
</h2>
<p className="text-sm text-gray-700 leading-relaxed">
Mike ("we," "our," or "us") is committed to protecting
your privacy. This Privacy Policy explains how we
collect, use, disclose, and safeguard your information
when you use our legal research service.
</p>
</section>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">
2. Information We Collect
</h2>
<p className="text-sm text-gray-700 leading-relaxed mb-2">
We collect information that you provide directly to us,
including:
</p>
<ul className="list-disc pl-6 mb-3 text-sm text-gray-700 space-y-1">
<li>Email address and account credentials</li>
<li>Search queries and research history</li>
<li>Chat conversations with our AI assistant</li>
<li>Usage data and preferences within the service</li>
</ul>
</section>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">
3. How We Use Your Information
</h2>
<p className="text-sm text-gray-700 leading-relaxed mb-2">
We use the information we collect to:
</p>
<ul className="list-disc pl-6 mb-3 text-sm text-gray-700 space-y-1">
<li>Provide, maintain, and improve our services</li>
<li>Process your requests and transactions</li>
<li>Send you technical notices and support messages</li>
<li>Respond to your comments and questions</li>
<li>Develop new features and improve our AI models</li>
</ul>
</section>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">
4. Information Sharing and Disclosure
</h2>
<p className="text-sm text-gray-700 leading-relaxed mb-2">
We do not sell your personal information. We may share
your information only in the following circumstances:
</p>
<ul className="list-disc pl-6 mb-3 text-sm text-gray-700 space-y-1">
<li>With your consent</li>
<li>
To comply with legal obligations or court orders
</li>
<li>
To protect our rights, privacy, safety, or property
</li>
<li>
With service providers who assist in our operations
</li>
</ul>
</section>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">
5. Data Security
</h2>
<p className="text-sm text-gray-700 leading-relaxed">
We implement appropriate technical and organizational
measures to protect your personal information. However,
no method of transmission over the Internet or
electronic storage is 100% secure, and we cannot
guarantee absolute security.
</p>
</section>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">
6. Data Retention
</h2>
<p className="text-sm text-gray-700 leading-relaxed">
We retain your personal information for as long as
necessary to provide our services and fulfill the
purposes outlined in this Privacy Policy, unless a
longer retention period is required by law.
</p>
</section>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">7. Your Rights</h2>
<p className="text-sm text-gray-700 leading-relaxed mb-2">
You have the right to:
</p>
<ul className="list-disc pl-6 mb-3 text-sm text-gray-700 space-y-1">
<li>Access and receive a copy of your data</li>
<li>Correct inaccurate or incomplete data</li>
<li>Request deletion of your data</li>
<li>Object to or restrict data processing</li>
<li>Data portability</li>
</ul>
</section>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">
8. Cookies and Tracking Technologies
</h2>
<p className="text-sm text-gray-700 leading-relaxed">
We use cookies and similar tracking technologies to
collect and track information about your usage of our
service. You can control cookies through your browser
settings.
</p>
</section>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">
9. Children's Privacy
</h2>
<p className="text-sm text-gray-700 leading-relaxed">
Our service is not intended for children under 13 years
of age. We do not knowingly collect personal information
from children under 13.
</p>
</section>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">
10. Changes to This Privacy Policy
</h2>
<p className="text-sm text-gray-700 leading-relaxed">
We may update this Privacy Policy from time to time. We
will notify you of any changes by posting the new
Privacy Policy on this page and updating the "Last
updated" date.
</p>
</section>
<section className="mb-6">
<h2 className="text-xl font-medium mb-3">11. Contact Us</h2>
<p className="text-sm text-gray-700 leading-relaxed">
If you have any questions about this Privacy Policy,
please contact us at team@mikeoss.com.
</p>
</section>
</div>
</main>
);
}

View file

@ -9,6 +9,7 @@ import Link from "next/link";
import { SiteLogo } from "@/components/site-logo";
import { CheckCircle2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { updateUserProfile } from "@/app/lib/mikeApi";
export default function SignupPage() {
const router = useRouter();
@ -59,19 +60,12 @@ export default function SignupPage() {
const trimmedName = name.trim();
const trimmedOrg = organisation.trim();
if (trimmedName || trimmedOrg) {
// The handle_new_user DB trigger creates the
// user_profiles row synchronously on auth.users insert,
// so we UPDATE rather than upsert — RLS permits update
// of the user's own row but blocks self-INSERT.
const { error: profileError } = await supabase
.from("user_profiles")
.update({
...(trimmedName && { display_name: trimmedName }),
try {
await updateUserProfile({
...(trimmedName && { displayName: trimmedName }),
...(trimmedOrg && { organisation: trimmedOrg }),
updated_at: new Date().toISOString(),
})
.eq("user_id", data.session.user.id);
if (profileError) {
});
} catch (profileError) {
console.error(
"[signup] failed to persist profile fields",
profileError,
@ -83,8 +77,12 @@ export default function SignupPage() {
setTimeout(() => {
router.push("/assistant");
}, 2000);
} catch (error: any) {
setError(error.message || "An error occurred during signup");
} catch (error: unknown) {
setError(
error instanceof Error
? error.message
: "An error occurred during signup",
);
} finally {
setLoading(false);
}
@ -121,7 +119,7 @@ export default function SignupPage() {
<SiteLogo size="md" className="md:text-4xl" asLink />
</div>
<div className="w-full max-w-md">
<div className="bg-white border border-gray-200 rounded-2xl p-8">
<div className="bg-white border border-gray-200 rounded-2xl p-8 mb-4">
<div className="flex justify-between items-center mb-6">
<h2 className="text-left text-2xl font-serif">
Create Account
@ -275,6 +273,12 @@ export default function SignupPage() {
</Link>
</div>
</div>
<p className="text-center text-xs text-gray-500 leading-relaxed px-2">
Mike hosted on MikeOSS.com is currently a demo service.
Please do not upload, submit, or store sensitive,
confidential, privileged, client, or personally identifiable
documents.
</p>
</div>
</div>
);

View file

@ -0,0 +1,273 @@
"use client";
import { useState, useEffect } from "react";
import { Send, CheckCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
type FeedbackType = "bug" | "feature" | "question" | "other";
export default function SupportPage() {
const router = useRouter();
const { user, isAuthenticated, authLoading } = useAuth();
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push("/");
}
}, [authLoading, isAuthenticated, router]);
const [feedbackType, setFeedbackType] = useState<FeedbackType>("question");
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [link, setLink] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const feedbackTypes: {
value: FeedbackType;
label: string;
description: string;
}[] = [
{
value: "bug",
label: "Bug Report",
description: "Report something that isn't working",
},
{
value: "feature",
label: "Feature Request",
description: "Suggest a new feature or improvement",
},
{
value: "question",
label: "Question",
description: "Ask a question about using Mike",
},
{
value: "other",
label: "Other",
description: "General feedback or other inquiries",
},
];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
const response = await fetch("/api/support", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: feedbackType,
subject,
message,
email: user?.email,
link,
}),
});
if (!response.ok) {
throw new Error("Failed to submit feedback");
}
setIsSubmitted(true);
} catch (err) {
console.error("Error submitting feedback:", err);
setError("Failed to submit your feedback. Please try again.");
} finally {
setIsSubmitting(false);
}
};
if (isSubmitted) {
return (
<div className="h-full flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-xl text-center">
<div className="flex justify-center mb-4">
<div className="h-16 w-16 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="h-8 w-8 text-green-600" />
</div>
</div>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
Thank you for helping us improve.
</h2>
<p className="text-gray-600 mb-6">
We will get in touch with you soon via email.
</p>
<button
onClick={() => router.push("/")}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
>
Back to Home
</button>
</div>
</div>
);
}
return (
<div className="w-full flex flex-col px-6 h-full">
<div className="w-full max-w-4xl m-auto flex flex-col h-full">
{/* Fixed Header Section */}
<div className="flex-shrink-0 pt-6 md:pt-10 pb-0">
<div className="mb-5">
<h1 className="text-4xl font-medium font-eb-garamond text-gray-900 mb-3">
Support
</h1>
</div>
</div>
{/* Form Container */}
<div className="flex-1 overflow-y-auto pb-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Feedback Type Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
What can we help you with?
</label>
<div className="grid grid-cols-2 gap-3">
{feedbackTypes.map((type) => (
<button
key={type.value}
type="button"
onClick={() =>
setFeedbackType(type.value)
}
className={`p-4 rounded-lg border-2 text-left transition-all ${
feedbackType === type.value
? "border-blue-600 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
>
<div
className={`font-medium ${
feedbackType === type.value
? "text-blue-700"
: "text-gray-900"
}`}
>
{type.label}
</div>
<div className="text-xs text-gray-500 mt-1">
{type.description}
</div>
</button>
))}
</div>
</div>
{/* Link (for bugs) */}
{feedbackType === "bug" && (
<div>
<label
htmlFor="link"
className="block text-sm font-medium text-gray-700 mb-2"
>
Link to issue (optional)
</label>
<input
type="url"
id="link"
value={link}
onChange={(e) =>
setLink(e.target.value)
}
placeholder="https://mikeoss.com/..."
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
/>
<p className="text-xs text-gray-500 mt-1">
If the bug is in a chat, mouseover the
chat in the sidebar, click the dots,
then click share and paste the link
here.
</p>
</div>
)}
{/* Subject */}
<div>
<label
htmlFor="subject"
className="block text-sm font-medium text-gray-700 mb-2"
>
Subject
</label>
<input
type="text"
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
required
/>
</div>
{/* Message */}
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-gray-700 mb-2"
>
Message
</label>
<textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Please describe your question, issue, or suggestion in detail..."
rows={5}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all resize-none"
required
/>
</div>
{/* Email Display (if logged in) */}
{user?.email && (
<div className="text-sm text-gray-500">
We'll respond to:{" "}
<span className="font-medium">
{user.email}
</span>
</div>
)}
{/* Error Message */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={
isSubmitting ||
!subject.trim() ||
!message.trim()
}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center justify-center gap-2"
>
{isSubmitting ? (
<>
<div className="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>Sending...</span>
</>
) : (
<>
<Send className="h-4 w-4" />
<span>Submit</span>
</>
)}
</button>
</form>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,202 @@
"use client";
const lastUpdated = "May 2, 2026";
const sections = [
{
title: "1. Acceptance of Terms",
body: [
"Welcome to Mike. These Terms of Service are a legally binding agreement between you and Mike regarding your access to and use of our website, hosted application, open-source software, APIs, and related services.",
"By creating an account, clicking to accept these Terms, or using the Service, you acknowledge that you have read, understood, and agree to be bound by these Terms and our Privacy Policy. If you do not agree, you may not use the Service.",
],
},
{
title: "2. Service Overview",
body: [
"Mike provides legal AI workflow tools, including document upload, project workspaces, document chat, citations, tabular review, reusable workflows, and document drafting or editing features.",
"Mike hosted on MikeOSS.com is currently provided as a demo service for evaluation and testing purposes only. You should not upload, submit, transmit, or store sensitive, confidential, privileged, proprietary, personally identifiable, client, or otherwise restricted information through the Service. Use the Service only with non-sensitive materials and at your own risk.",
"The Service may connect to third-party large language model providers, hosting providers, authentication services, storage services, and payment or infrastructure providers. We may add, remove, suspend, or modify features or third-party integrations at any time.",
],
},
{
title: "3. Eligibility and Authority",
body: [
"You must be at least 13 years old to use the Service. If you are under 18, you must have permission from a parent or legal guardian.",
"If you use the Service on behalf of a company, law firm, organization, or other entity, you represent that you have authority to bind that entity to these Terms.",
],
},
{
title: "4. Accounts and Security",
body: [
"You may need an account to access most features. You agree to provide accurate account information and to keep it up to date.",
"You are responsible for maintaining the confidentiality of your account credentials and for all activity under your account. If you believe your account is compromised, contact us promptly at team@mikeoss.com.",
],
},
{
title: "5. Fees, Credits, and Third-Party Costs",
body: [
"Some features may be free, metered, usage-limited, or paid. We may introduce or change fees, plans, credits, quotas, or usage limits with notice where required by law.",
"If you connect your own third-party AI provider API keys, you are responsible for any charges, usage limits, provider terms, or account restrictions imposed by those providers.",
"Unless otherwise stated at the time of purchase, fees are non-refundable except where required by law.",
],
},
{
title: "6. User Content and AI Outputs",
body: [
"You may submit documents, prompts, text, files, data, and other materials to the Service (\"Input\") and receive AI-generated or system-generated responses, summaries, extractions, drafts, edits, citations, or other content (\"Output\"). Input and Output are collectively \"User Content.\"",
"As between you and Mike, you retain any rights you have in your Input. Subject to applicable law and third-party provider terms, you are responsible for evaluating and using Output.",
"You grant Mike a limited license to host, store, process, transmit, display, and otherwise use User Content as necessary to provide, secure, troubleshoot, improve, and support the Service.",
"You represent that you have all rights and permissions necessary to submit Input to the Service and that your Input and use of the Service will not violate law, third-party rights, confidentiality duties, court orders, professional obligations, or applicable provider terms.",
],
},
{
title: "7. Legal and Professional Responsibility",
body: [
"Mike is a software tool. It does not provide legal, financial, tax, regulatory, compliance, or professional advice, and it does not create an attorney-client relationship.",
"AI systems can produce inaccurate, incomplete, outdated, or misleading Output. You are solely responsible for reviewing, verifying, and exercising professional judgment before relying on any Output or using it in client work, filings, transactions, negotiations, or legal advice.",
],
},
{
title: "8. Third-Party AI Models and Services",
body: [
"The Service may route Input to third-party AI models or infrastructure providers selected by you, configured by your account, or made available through the Service.",
"Your use of third-party models or services may be subject to additional terms, policies, data practices, retention settings, training settings, and usage restrictions. We are not responsible for third-party services, model availability, model behavior, pricing, outages, or provider terms.",
],
},
{
title: "9. Prohibited Conduct",
body: [
"You agree not to use the Service for unlawful, harmful, infringing, deceptive, abusive, or security-compromising activity.",
"You may not attempt to gain unauthorized access to the Service or any account, interfere with the Service, upload malware, scrape or copy the Service except as permitted by law or applicable open-source licenses, bypass usage limits, misrepresent your identity, or use the Service in violation of any third-party AI provider terms.",
"You may not submit Input that you do not have the right to use, that violates confidentiality or privacy obligations, or that infringes intellectual property or other third-party rights.",
],
},
{
title: "10. Open-Source Software and Ownership",
body: [
"Certain Mike software may be made available under open-source licenses. Your use, copying, modification, and distribution of that open-source software is governed by the applicable open-source license, not these Terms.",
"The hosted Service, website, brand, design, trade names, hosted infrastructure, documentation, and non-open-source elements are owned by Mike or its licensors and are protected by intellectual property and other laws.",
],
},
{
title: "11. Feedback",
body: [
"If you provide comments, suggestions, ideas, or feedback, you grant us a perpetual, irrevocable, worldwide, royalty-free license to use that feedback for any purpose without obligation to compensate you.",
],
},
{
title: "12. Confidentiality",
body: [
"Each party may receive non-public information from the other in connection with the Service. The receiving party will use reasonable care to protect confidential information and will use it only for purposes related to the Service, except where disclosure is required by law or authorized by the disclosing party.",
"Confidential information does not include information that is public through no fault of the receiving party, already known without a confidentiality duty, lawfully received from a third party, independently developed, or submitted as feedback.",
],
},
{
title: "13. Privacy and Data Protection",
body: [
"Please review our Privacy Policy for information about how we collect, use, store, and disclose personal information. The Privacy Policy is incorporated into these Terms.",
"If you use the Service on behalf of an organization and require a data processing agreement, contact us at team@mikeoss.com.",
],
},
{
title: "14. Suspension and Termination",
body: [
"You may stop using the Service at any time. We may suspend or terminate your access to the Service if you violate these Terms, create risk for the Service or other users, or if we discontinue the Service or any material feature.",
"Upon termination, your right to use the Service ends, but provisions that by their nature should survive will survive, including provisions about User Content, ownership, confidentiality, disclaimers, limitations of liability, indemnity, and dispute resolution.",
],
},
{
title: "15. Disclaimers",
body: [
"THE SERVICE, OUTPUT, MATERIALS, AND ALL CONTENT AVAILABLE THROUGH THE SERVICE ARE PROVIDED \"AS IS\" AND \"AS AVAILABLE\" WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, OR STATUTORY.",
"TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE DISCLAIM ALL WARRANTIES, INCLUDING IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, QUIET ENJOYMENT, NON-INFRINGEMENT, ACCURACY, AVAILABILITY, SECURITY, AND RELIABILITY.",
"WE DO NOT WARRANT THAT THE SERVICE OR OUTPUT WILL BE UNINTERRUPTED, ERROR-FREE, SECURE, CURRENT, COMPLETE, OR SUITABLE FOR ANY PARTICULAR LEGAL OR PROFESSIONAL USE.",
],
},
{
title: "16. Limitation of Liability",
body: [
"TO THE MAXIMUM EXTENT PERMITTED BY LAW, MIKE AND ITS AFFILIATES, OFFICERS, EMPLOYEES, CONTRACTORS, AGENTS, SUPPLIERS, AND LICENSORS WILL NOT BE LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR LOST PROFITS, LOST REVENUE, LOST DATA, LOSS OF GOODWILL, BUSINESS INTERRUPTION, OR SUBSTITUTE SERVICES.",
"THE SERVICE IS PROVIDED FREE OF CHARGE. TO THE MAXIMUM EXTENT PERMITTED BY LAW, MIKE WILL NOT BE LIABLE FOR ANY DAMAGES ARISING OUT OF OR RELATING TO THE SERVICE OR THESE TERMS.",
],
},
{
title: "17. Indemnity",
body: [
"You will defend, indemnify, and hold harmless Mike and its affiliates, officers, employees, contractors, agents, suppliers, and licensors from and against claims, liabilities, damages, losses, and expenses, including reasonable attorneys' fees, arising from your use of the Service, your User Content, your violation of these Terms, your violation of law, or your violation of third-party rights.",
],
},
{
title: "18. Changes to These Terms",
body: [
"We may modify these Terms from time to time. If changes materially affect your rights or obligations, we will provide reasonable notice, such as by posting the updated Terms or sending an email or in-product notice.",
"Your continued use of the Service after the effective date of updated Terms means you accept the updated Terms. If you do not agree, you must stop using the Service.",
],
},
{
title: "19. Governing Law and Dispute Resolution",
body: [
"These Terms are governed by the laws of the State of New York, without regard to conflict of law principles, unless applicable law requires otherwise.",
"Before filing a claim, each party agrees to try to resolve the dispute informally by contacting the other party. You may contact us at team@mikeoss.com. If the dispute is not resolved within 30 days, either party may pursue available remedies in a court of competent jurisdiction.",
"You and Mike agree that claims must be brought only in an individual capacity and not as a plaintiff or class member in any class, collective, consolidated, private attorney general, or representative proceeding, to the maximum extent permitted by law.",
],
},
{
title: "20. Electronic Communications",
body: [
"By using the Service, you consent to receive communications from us electronically. Electronic communications may include notices, account messages, product updates, and legal disclosures. You agree that electronic communications satisfy any legal requirement that such communications be in writing.",
],
},
{
title: "21. Contact",
body: [
"If you have questions about these Terms, contact us at team@mikeoss.com.",
],
},
];
export default function TermsPage() {
return (
<main className="w-full px-6 py-6 md:py-10">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-medium font-eb-garamond mb-3">
Terms of Service
</h1>
<p className="mb-6 text-sm text-gray-500">
Last Updated: {lastUpdated}
</p>
<div className="mb-8 rounded-md border border-amber-200 bg-amber-50 p-4">
<p className="text-sm font-medium text-amber-900 mb-1">
Demo service notice
</p>
<p className="text-sm text-amber-800 leading-relaxed">
Mike hosted on MikeOSS.com is currently provided as a
demo service. Do not upload, submit, or store
sensitive, confidential, privileged, proprietary,
client, or personally identifiable documents or
information through the Service.
</p>
</div>
<div className="space-y-7">
{sections.map((section) => (
<section key={section.title}>
<h2 className="text-xl font-medium mb-3">
{section.title}
</h2>
<div className="space-y-3">
{section.body.map((paragraph) => (
<p
key={paragraph}
className="text-sm text-gray-700 leading-relaxed"
>
{paragraph}
</p>
))}
</div>
</section>
))}
</div>
</div>
</main>
);
}

View file

@ -28,17 +28,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [authLoading, setAuthLoading] = useState(true);
useEffect(() => {
const ensureProfile = async (accessToken: string) => {
const apiBase =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
await fetch(`${apiBase}/user/profile`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
}).catch((e) => {
console.log(e);
});
};
const checkUser = async () => {
const {
data: { session },
@ -49,7 +38,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
id: session.user.id,
email: session.user.email || "",
});
ensureProfile(session.access_token);
}
setAuthLoading(false);
};
@ -64,7 +52,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
id: session.user.id,
email: session.user.email || "",
});
ensureProfile(session.access_token);
} else {
setUser(null);
}

View file

@ -8,8 +8,13 @@ import React, {
ReactNode,
useCallback,
} from "react";
import { supabase } from "@/lib/supabase";
import { useAuth } from "@/contexts/AuthContext";
import {
type UserProfile as ApiUserProfile,
getUserProfile,
saveApiKey,
updateUserProfile,
} from "@/app/lib/mikeApi";
interface UserProfile {
displayName: string | null;
@ -44,95 +49,27 @@ const UserProfileContext = createContext<UserProfileContextType | undefined>(
undefined,
);
const CONFIGURED_KEY_MARKER = "configured";
function toProfile(data: ApiUserProfile): UserProfile {
const { apiKeyStatus, ...profile } = data;
return {
...profile,
claudeApiKey: apiKeyStatus.claude ? CONFIGURED_KEY_MARKER : null,
geminiApiKey: apiKeyStatus.gemini ? CONFIGURED_KEY_MARKER : null,
};
}
export function UserProfileProvider({ children }: { children: ReactNode }) {
const { user, isAuthenticated } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const loadProfile = useCallback(async (userId: string) => {
const loadProfile = useCallback(async () => {
try {
const { data, error } = await supabase
.from("user_profiles")
.select("*")
.eq("user_id", userId)
.single();
// Define credit limit constant
const MONTHLY_CREDIT_LIMIT = 999999; // temporarily unlimited
// Calculate a default future reset date (30 days from now)
const futureResetDate = new Date();
futureResetDate.setDate(futureResetDate.getDate() + 30);
const defaultResetDateStr = futureResetDate.toISOString();
if (error) {
// Set fallback profile data if profile doesn't exist
setProfile({
displayName: null,
organisation: null,
messageCreditsUsed: 0,
creditsResetDate: defaultResetDateStr,
creditsRemaining: MONTHLY_CREDIT_LIMIT,
tier: "Free",
tabularModel: "gemini-3-flash-preview",
claudeApiKey: null,
geminiApiKey: null,
});
return;
}
// Use fetched data to update profile state
if (data) {
let creditsUsed = data.message_credits_used;
let resetDate = data.credits_reset_date;
let creditsRemaining = MONTHLY_CREDIT_LIMIT - creditsUsed;
let shouldUpdateDb = false;
// Check if credits have expired and need reset
if (resetDate && new Date() > new Date(resetDate)) {
// Calculate new reset date
const newResetDate = new Date();
newResetDate.setDate(newResetDate.getDate() + 30);
resetDate = newResetDate.toISOString();
creditsUsed = 0;
creditsRemaining = MONTHLY_CREDIT_LIMIT;
shouldUpdateDb = true;
}
// 1. Update local state immediately
setProfile({
displayName: data.display_name,
organisation: data.organisation ?? null,
messageCreditsUsed: creditsUsed,
creditsResetDate: resetDate,
creditsRemaining: creditsRemaining,
tier: data.tier || "Free",
tabularModel:
data.tabular_model || "gemini-3-flash-preview",
claudeApiKey: data.claude_api_key ?? null,
geminiApiKey: data.gemini_api_key ?? null,
});
// 2. Update database in background if needed
if (shouldUpdateDb) {
supabase
.from("user_profiles")
.update({
message_credits_used: 0,
credits_reset_date: resetDate,
updated_at: new Date().toISOString(),
})
.eq("user_id", userId)
.then(({ error }) => {
if (error)
console.error(
"Failed to auto-reset credits",
error,
);
});
}
}
} catch (e) {
const profileData = await getUserProfile();
setProfile(toProfile(profileData));
} catch {
// Calculate a default future reset date for fallback
const futureResetDate = new Date();
futureResetDate.setDate(futureResetDate.getDate() + 30);
@ -157,7 +94,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (isAuthenticated && user) {
setLoading(true);
loadProfile(user.id);
loadProfile();
} else {
setProfile(null);
setLoading(false);
@ -171,19 +108,10 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
}
try {
const { error } = await supabase
.from("user_profiles")
.update({
display_name: displayName,
updated_at: new Date().toISOString(),
})
.eq("user_id", user.id);
if (error) {
throw error;
}
setProfile((prev) => (prev ? { ...prev, displayName } : null));
const updated = await updateUserProfile({ displayName });
setProfile((prev) =>
prev ? { ...prev, ...toProfile(updated) } : null,
);
return true;
} catch {
return false;
@ -196,16 +124,9 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
async (organisation: string): Promise<boolean> => {
if (!user) return false;
try {
const { error } = await supabase
.from("user_profiles")
.update({
organisation,
updated_at: new Date().toISOString(),
})
.eq("user_id", user.id);
if (error) throw error;
const updated = await updateUserProfile({ organisation });
setProfile((prev) =>
prev ? { ...prev, organisation } : null,
prev ? { ...prev, ...toProfile(updated) } : null,
);
return true;
} catch {
@ -216,24 +137,15 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
);
const updateModelPreference = useCallback(
async (
field: "tabularModel",
value: string,
): Promise<boolean> => {
async (field: "tabularModel", value: string): Promise<boolean> => {
if (!user) return false;
const dbField = field === "tabularModel" ? "tabular_model" : "";
if (!dbField) return false;
if (field !== "tabularModel") return false;
try {
const { error } = await supabase
.from("user_profiles")
.update({
[dbField]: value,
updated_at: new Date().toISOString(),
})
.eq("user_id", user.id);
if (error) throw error;
const updated = await updateUserProfile({
tabularModel: value,
});
setProfile((prev) =>
prev ? { ...prev, [field]: value } : null,
prev ? { ...prev, ...toProfile(updated) } : null,
);
return true;
} catch {
@ -249,22 +161,20 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
value: string | null,
): Promise<boolean> => {
if (!user) return false;
const dbField =
provider === "claude" ? "claude_api_key" : "gemini_api_key";
const stateField =
provider === "claude" ? "claudeApiKey" : "geminiApiKey";
const normalized = value?.trim() ? value.trim() : null;
try {
const { error } = await supabase
.from("user_profiles")
.update({
[dbField]: normalized,
updated_at: new Date().toISOString(),
})
.eq("user_id", user.id);
if (error) throw error;
await saveApiKey(provider, normalized);
setProfile((prev) =>
prev ? { ...prev, [stateField]: normalized } : null,
prev
? {
...prev,
[stateField]: normalized
? CONFIGURED_KEY_MARKER
: null,
}
: null,
);
return true;
} catch {
@ -276,7 +186,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
const reloadProfile = useCallback(async () => {
if (user) {
await loadProfile(user.id);
await loadProfile();
}
}, [user, loadProfile]);
@ -290,36 +200,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
return false;
}
try {
const newCreditsUsed = profile.messageCreditsUsed + 1;
const { error } = await supabase
.from("user_profiles")
.update({
message_credits_used: newCreditsUsed,
updated_at: new Date().toISOString(),
})
.eq("user_id", user.id);
if (error) {
throw error;
}
// Update local state
setProfile((prev) =>
prev
? {
...prev,
messageCreditsUsed: newCreditsUsed,
creditsRemaining: 999999 - newCreditsUsed, // temporarily unlimited
}
: null,
);
return true;
} catch (err) {
return false;
}
return false;
}, [user, profile]);
return (

View file

@ -3,7 +3,7 @@ import { NextRequest } from 'next/server';
/**
* Extract and validate user from Supabase JWT token
* Returns user info if valid, null if invalid or missing
*
*
* @param request NextRequest with Authorization header
* @returns User object with email and id, or null
*/
@ -13,17 +13,17 @@ export async function getUserFromRequest(request: NextRequest): Promise<{
} | null> {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.substring(7);
if (!token) {
return null;
}
// Validate with Supabase
const { createClient } = await import('@supabase/supabase-js');
const supabase = createClient(
@ -32,7 +32,7 @@ export async function getUserFromRequest(request: NextRequest): Promise<{
);
const { data: { user }, error } = await supabase.auth.getUser(token);
if (error || !user) {
console.warn('[Auth] Invalid or expired token:', error?.message);
return null;
@ -53,3 +53,4 @@ export async function getUserFromRequest(request: NextRequest): Promise<{
return null;
}
}