mirror of
https://github.com/willchen96/mike.git
synced 2026-06-18 21:15:13 +02:00
Merge branch 'main' into codex/safe-local-testing-guide
This commit is contained in:
commit
1f191fea59
40 changed files with 3061 additions and 862 deletions
|
|
@ -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",
|
||||
|
|
|
|||
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
BIN
frontend/public/link-image.jpg
Normal file
BIN
frontend/public/link-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
13
frontend/src/app/(pages)/projects/[id]/assistant/page.tsx
Normal file
13
frontend/src/app/(pages)/projects/[id]/assistant/page.tsx
Normal 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" />;
|
||||
}
|
||||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
160
frontend/src/app/privacy/page.tsx
Normal file
160
frontend/src/app/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
273
frontend/src/app/support/page.tsx
Normal file
273
frontend/src/app/support/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
frontend/src/app/terms/page.tsx
Normal file
202
frontend/src/app/terms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue