mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 23:48:26 +00:00
Fix: echo v2
This commit is contained in:
parent
72edab887e
commit
060668e8c0
4
apps/webapp/.prettierrc
Normal file
4
apps/webapp/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tailwindConfig": "./tailwind.config.ts",
|
||||||
|
"plugins": [ "prettier-plugin-tailwindcss" ]
|
||||||
|
}
|
||||||
@ -1,40 +1,43 @@
|
|||||||
# Welcome to Remix!
|
# Memory Plane
|
||||||
|
|
||||||
- 📖 [Remix docs](https://remix.run/docs)
|
Simple memory management system for AI agents with per-space ingestion and search capabilities.
|
||||||
|
|
||||||
## Development
|
## Core APIs
|
||||||
|
|
||||||
Run the dev server:
|
### 1. Ingest API
|
||||||
|
|
||||||
```shellscript
|
- Endpoint per space for data ingestion
|
||||||
npm run dev
|
- Queue-based processing per user
|
||||||
```
|
- Creates and links graph nodes automatically
|
||||||
|
- Optional review queue for controlled ingestion
|
||||||
|
|
||||||
## Deployment
|
### 2. Search API
|
||||||
|
|
||||||
First, build your app for production:
|
- Simple text-based search
|
||||||
|
- Input: query string
|
||||||
|
- Output: relevant text matches
|
||||||
|
- Scoped to specific memory space
|
||||||
|
|
||||||
```sh
|
## Features (v1)
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the app in production mode:
|
[ ] Auto-mode default with optional queue review
|
||||||
|
[ ] Multiple Spaces support (unique URL per space)
|
||||||
|
[ ] Basic rules engine for ingestion filters
|
||||||
|
[ ] Clear, user-friendly guidelines
|
||||||
|
[ ] Simple text search
|
||||||
|
|
||||||
```sh
|
## Usage Guidelines
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
Now you'll need to pick a host to deploy it to.
|
Store:
|
||||||
|
|
||||||
### DIY
|
- Conversation history
|
||||||
|
- User preferences
|
||||||
|
- Task context
|
||||||
|
- Reference materials
|
||||||
|
|
||||||
If you're familiar with deploying Node applications, the built-in Remix app server is production-ready.
|
Don't Store:
|
||||||
|
|
||||||
Make sure to deploy the output of `npm run build`
|
- Sensitive data (PII)
|
||||||
|
- Credentials
|
||||||
- `build/server`
|
- System logs
|
||||||
- `build/client`
|
- Temporary data
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever css framework you prefer. See the [Vite docs on css](https://vitejs.dev/guide/features.html#css) for more information.
|
|
||||||
|
|||||||
61
apps/webapp/app/components/ErrorDisplay.tsx
Normal file
61
apps/webapp/app/components/ErrorDisplay.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
|
||||||
|
|
||||||
|
import { friendlyErrorDisplay } from "~/utils/httpErrors";
|
||||||
|
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { Button } from "./ui";
|
||||||
|
import { Header1 } from "./ui/Headers";
|
||||||
|
import { Paragraph } from "./ui/Paragraph";
|
||||||
|
|
||||||
|
type ErrorDisplayOptions = {
|
||||||
|
button?: {
|
||||||
|
title: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RouteErrorDisplay(options?: ErrorDisplayOptions) {
|
||||||
|
const error = useRouteError();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isRouteErrorResponse(error) ? (
|
||||||
|
<ErrorDisplay
|
||||||
|
title={friendlyErrorDisplay(error.status, error.statusText).title}
|
||||||
|
message={
|
||||||
|
error.data.message ??
|
||||||
|
friendlyErrorDisplay(error.status, error.statusText).message
|
||||||
|
}
|
||||||
|
{...options}
|
||||||
|
/>
|
||||||
|
) : error instanceof Error ? (
|
||||||
|
<ErrorDisplay title={error.name} message={error.message} {...options} />
|
||||||
|
) : (
|
||||||
|
<ErrorDisplay
|
||||||
|
title="Oops"
|
||||||
|
message={JSON.stringify(error)}
|
||||||
|
{...options}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisplayOptionsProps = {
|
||||||
|
title: string;
|
||||||
|
message?: ReactNode;
|
||||||
|
} & ErrorDisplayOptions;
|
||||||
|
|
||||||
|
export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background relative flex min-h-screen flex-col items-center justify-center">
|
||||||
|
<div className="z-10 mt-[30vh] flex flex-col items-center gap-8">
|
||||||
|
<Header1>{title}</Header1>
|
||||||
|
{message && <Paragraph>{message}</Paragraph>}
|
||||||
|
<Button variant="link">
|
||||||
|
{button ? button.title : "Go to homepage"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/webapp/app/components/layout/AppLayout.tsx
Normal file
75
apps/webapp/app/components/layout/AppLayout.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** This container is used to surround the entire app, it correctly places the nav bar */
|
||||||
|
export function AppContainer({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainBody({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className={cn("grid grid-rows-1 overflow-hidden")}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This container should be placed around the content on a page */
|
||||||
|
export function PageContainer({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="grid grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageBody({
|
||||||
|
children,
|
||||||
|
scrollable = true,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
scrollable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
scrollable
|
||||||
|
? "scrollbar-thumb-charcoal-600 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent"
|
||||||
|
: "overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainCenteredContainer({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="scrollbar-thumb-charcoal-600 h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent">
|
||||||
|
<div className={cn("mx-auto mt-6 max-w-xs overflow-y-auto p-1 md:mt-[22vh]", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainHorizontallyCenteredContainer({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="scrollbar-thumb-charcoal-600 w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"scrollbar-thumb-charcoal-600 mx-auto mt-6 max-w-lg overflow-y-auto p-1 scrollbar-thin scrollbar-track-transparent md:mt-14",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/webapp/app/components/layout/LoginPageLayout.tsx
Normal file
51
apps/webapp/app/components/layout/LoginPageLayout.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Paragraph } from "../ui/Paragraph";
|
||||||
|
import { Header3 } from "../ui/Headers";
|
||||||
|
import { Button } from "../ui";
|
||||||
|
import Logo from "../logo/logo";
|
||||||
|
import { Theme, useTheme } from "remix-themes";
|
||||||
|
|
||||||
|
interface QuoteType {
|
||||||
|
quote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotes: QuoteType[] = [
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
"Echo remembers that I prefer emails in dark mode and hate promotional content. It automatically filters and formats my communications just the way I like.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
"When I mention liking Nike's latest running shoes, Echo remembers this preference and helps surface relevant product launches and deals across my browsing.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
"Echo knows I'm a vegetarian and helps filter restaurant recommendations and recipes accordingly, without me having to specify it every time.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
"By remembering that I prefer technical documentation with code examples, Echo helps prioritize learning resources that match my learning style.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const randomIndex = Math.floor(Math.random() * quotes.length);
|
||||||
|
setRandomQuote(quotes[randomIndex]);
|
||||||
|
}, []);
|
||||||
|
const [, setTheme] = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||||
|
<div className="pt-8">
|
||||||
|
<Logo width={20} height={20} />
|
||||||
|
<Button onClick={() => setTheme(Theme.DARK)}>theme</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-full flex-grow items-center justify-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/webapp/app/components/logo/index.ts
Normal file
1
apps/webapp/app/components/logo/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./logo";
|
||||||
67
apps/webapp/app/components/logo/logo.tsx
Normal file
67
apps/webapp/app/components/logo/logo.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Theme, useTheme } from "remix-themes";
|
||||||
|
|
||||||
|
export interface LogoProps {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StaticLogo({ width, height }: LogoProps) {
|
||||||
|
const [theme] = useTheme();
|
||||||
|
|
||||||
|
if (theme === Theme.LIGHT) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="153"
|
||||||
|
height="153"
|
||||||
|
viewBox="0 0 153 153"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g clip-path="url(#clip0_47_2)">
|
||||||
|
<path
|
||||||
|
d="M43 40H110V62.2H72.7778V77H110V114H43V91.8H80.2222V77H43V40Z"
|
||||||
|
fill="black"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_47_2">
|
||||||
|
<path
|
||||||
|
d="M0 12C0 5.37258 5.37258 0 12 0H141C147.627 0 153 5.37258 153 12V141C153 147.627 147.627 153 141 153H12C5.37258 153 0 147.627 0 141V12Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="153"
|
||||||
|
height="153"
|
||||||
|
viewBox="0 0 153 153"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g clip-path="url(#clip0_55_2)">
|
||||||
|
<path
|
||||||
|
d="M0 12C0 5.37258 5.37258 0 12 0H141C147.627 0 153 5.37258 153 12V141C153 147.627 147.627 153 141 153H12C5.37258 153 0 147.627 0 141V12Z"
|
||||||
|
fill="#1E1E1E"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M43 40H110V62.2H72.7778V77H110V114H43V91.8H80.2222V77H43V40Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_55_2">
|
||||||
|
<path
|
||||||
|
d="M0 12C0 5.37258 5.37258 0 12 0H141C147.627 0 153 5.37258 153 12V141C153 147.627 147.627 153 141 153H12C5.37258 153 0 147.627 0 141V12Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/webapp/app/components/ui/Headers.tsx
Normal file
90
apps/webapp/app/components/ui/Headers.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const headerVariants = {
|
||||||
|
header1: {
|
||||||
|
text: "font-sans text-2xl leading-5 md:leading-6 lg:leading-7 font-medium",
|
||||||
|
spacing: "mb-2",
|
||||||
|
},
|
||||||
|
header2: {
|
||||||
|
text: "font-sans text-base leading-6 font-medium",
|
||||||
|
spacing: "mb-2",
|
||||||
|
},
|
||||||
|
header3: {
|
||||||
|
text: "font-sans text-sm leading-5 font-medium",
|
||||||
|
spacing: "mb-2",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const textColorVariants = {
|
||||||
|
bright: "text-text-bright",
|
||||||
|
dimmed: "text-text-dimmed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeaderVariant = keyof typeof headerVariants;
|
||||||
|
|
||||||
|
type HeaderProps = {
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
spacing?: boolean;
|
||||||
|
textColor?: "bright" | "dimmed";
|
||||||
|
} & React.HTMLAttributes<HTMLHeadingElement>;
|
||||||
|
|
||||||
|
export function Header1({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
spacing = false,
|
||||||
|
textColor = "bright",
|
||||||
|
}: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<h1
|
||||||
|
className={cn(
|
||||||
|
headerVariants.header1.text,
|
||||||
|
spacing === true && headerVariants.header1.spacing,
|
||||||
|
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header2({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
spacing = false,
|
||||||
|
textColor = "bright",
|
||||||
|
}: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
className={cn(
|
||||||
|
headerVariants.header2.text,
|
||||||
|
spacing === true && headerVariants.header2.spacing,
|
||||||
|
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header3({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
spacing = false,
|
||||||
|
textColor = "bright",
|
||||||
|
}: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
className={cn(
|
||||||
|
headerVariants.header3.text,
|
||||||
|
spacing === true && headerVariants.header3.spacing,
|
||||||
|
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/webapp/app/components/ui/Paragraph.tsx
Normal file
95
apps/webapp/app/components/ui/Paragraph.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const paragraphVariants = {
|
||||||
|
base: {
|
||||||
|
text: "font-sans text-base font-normal text-text-dimmed",
|
||||||
|
spacing: "mb-3",
|
||||||
|
},
|
||||||
|
"base/bright": {
|
||||||
|
text: "font-sans text-base font-normal text-text-bright",
|
||||||
|
spacing: "mb-3",
|
||||||
|
},
|
||||||
|
small: {
|
||||||
|
text: "font-sans text-sm font-normal text-text-dimmed",
|
||||||
|
spacing: "mb-2",
|
||||||
|
},
|
||||||
|
"small/bright": {
|
||||||
|
text: "font-sans text-sm font-normal text-text-bright",
|
||||||
|
spacing: "mb-2",
|
||||||
|
},
|
||||||
|
"extra-small": {
|
||||||
|
text: "font-sans text-xs font-normal text-text-dimmed",
|
||||||
|
spacing: "mb-1.5",
|
||||||
|
},
|
||||||
|
"extra-small/bright": {
|
||||||
|
text: "font-sans text-xs font-normal text-text-bright",
|
||||||
|
spacing: "mb-1.5",
|
||||||
|
},
|
||||||
|
"extra-small/mono": {
|
||||||
|
text: "font-mono text-xs font-normal text-text-dimmed",
|
||||||
|
spacing: "mb-1.5",
|
||||||
|
},
|
||||||
|
"extra-small/bright/mono": {
|
||||||
|
text: "font-mono text-xs text-text-bright",
|
||||||
|
spacing: "mb-1.5",
|
||||||
|
},
|
||||||
|
"extra-small/caps": {
|
||||||
|
text: "font-sans text-xs uppercase tracking-wider font-normal text-text-dimmed",
|
||||||
|
spacing: "mb-1.5",
|
||||||
|
},
|
||||||
|
"extra-small/bright/caps": {
|
||||||
|
text: "font-sans text-xs uppercase tracking-wider font-normal text-text-bright",
|
||||||
|
spacing: "mb-1.5",
|
||||||
|
},
|
||||||
|
"extra-extra-small": {
|
||||||
|
text: "font-sans text-xxs font-normal text-text-dimmed",
|
||||||
|
spacing: "mb-1",
|
||||||
|
},
|
||||||
|
"extra-extra-small/bright": {
|
||||||
|
text: "font-sans text-xxs font-normal text-text-bright",
|
||||||
|
spacing: "mb-1",
|
||||||
|
},
|
||||||
|
|
||||||
|
"extra-extra-small/caps": {
|
||||||
|
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed",
|
||||||
|
spacing: "mb-1",
|
||||||
|
},
|
||||||
|
"extra-extra-small/bright/caps": {
|
||||||
|
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-bright",
|
||||||
|
spacing: "mb-1",
|
||||||
|
},
|
||||||
|
"extra-extra-small/dimmed/caps": {
|
||||||
|
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed",
|
||||||
|
spacing: "mb-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParagraphVariant = keyof typeof paragraphVariants;
|
||||||
|
|
||||||
|
type ParagraphProps = {
|
||||||
|
variant?: ParagraphVariant;
|
||||||
|
className?: string;
|
||||||
|
spacing?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
} & React.HTMLAttributes<HTMLParagraphElement>;
|
||||||
|
|
||||||
|
export function Paragraph({
|
||||||
|
variant = "base",
|
||||||
|
className,
|
||||||
|
spacing = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ParagraphProps) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
paragraphVariants[variant].text,
|
||||||
|
spacing === true && paragraphVariants[variant].spacing,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/webapp/app/components/ui/button.tsx
Normal file
88
apps/webapp/app/components/ui/button.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Loader } from "lucide-react";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded transition-colors focus-visible:outline-none focus-visible:shadow-none disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-slate-300",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-white shadow hover:bg-primary/90 dark:hover:bg-primary/90",
|
||||||
|
destructive: "text-red-500 bg-grayAlpha-100 border-none",
|
||||||
|
outline: "border border-border shadow-sm hover:bg-gray-100 shadow-none",
|
||||||
|
secondary: "bg-grayAlpha-100 border-none",
|
||||||
|
ghost: "dark:focus-visible:ring-0 hover:bg-grayAlpha-100",
|
||||||
|
link: "dark:focus-visible:ring-0",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-7 rounded px-2 py-1",
|
||||||
|
sm: "h-6 rounded-sm px-2 py-2",
|
||||||
|
xs: "h-5 rounded-sm px-1 py-1",
|
||||||
|
lg: "h-8 px-4 py-2",
|
||||||
|
xl: "h-9 rounded px-8",
|
||||||
|
"2xl": "h-12 rounded px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
false: "w-auto",
|
||||||
|
true: "w-full",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
full: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
isLoading?: boolean;
|
||||||
|
asChild?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
full,
|
||||||
|
asChild = false,
|
||||||
|
children,
|
||||||
|
isLoading,
|
||||||
|
isActive,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant, size, full, className }),
|
||||||
|
isActive && "bg-accent text-accent-foreground"
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
{...props}
|
||||||
|
disabled={isLoading ?? disabled}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader className="mr-2 animate-spin" /> : <></>}
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
1
apps/webapp/app/components/ui/index.ts
Normal file
1
apps/webapp/app/components/ui/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./button";
|
||||||
232
apps/webapp/app/db.server.ts
Normal file
232
apps/webapp/app/db.server.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import {
|
||||||
|
Prisma,
|
||||||
|
PrismaClient,
|
||||||
|
type PrismaClientOrTransaction,
|
||||||
|
type PrismaReplicaClient,
|
||||||
|
type PrismaTransactionClient,
|
||||||
|
type PrismaTransactionOptions,
|
||||||
|
$transaction as transac,
|
||||||
|
} from "@echo/database";
|
||||||
|
import invariant from "tiny-invariant";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { env } from "./env.server";
|
||||||
|
import { logger } from "./services/logger.service";
|
||||||
|
import { isValidDatabaseUrl } from "./utils/db";
|
||||||
|
import { singleton } from "./utils/singleton";
|
||||||
|
|
||||||
|
import { type Span } from "@opentelemetry/api";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PrismaTransactionClient,
|
||||||
|
PrismaClientOrTransaction,
|
||||||
|
PrismaTransactionOptions,
|
||||||
|
PrismaReplicaClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function $transaction<R>(
|
||||||
|
prisma: PrismaClientOrTransaction,
|
||||||
|
name: string,
|
||||||
|
fn: (prisma: PrismaTransactionClient, span?: Span) => Promise<R>,
|
||||||
|
options?: PrismaTransactionOptions
|
||||||
|
): Promise<R | undefined>;
|
||||||
|
export async function $transaction<R>(
|
||||||
|
prisma: PrismaClientOrTransaction,
|
||||||
|
fn: (prisma: PrismaTransactionClient) => Promise<R>,
|
||||||
|
options?: PrismaTransactionOptions
|
||||||
|
): Promise<R | undefined>;
|
||||||
|
export async function $transaction<R>(
|
||||||
|
prisma: PrismaClientOrTransaction,
|
||||||
|
fnOrName: ((prisma: PrismaTransactionClient) => Promise<R>) | string,
|
||||||
|
fnOrOptions?: ((prisma: PrismaTransactionClient) => Promise<R>) | PrismaTransactionOptions,
|
||||||
|
options?: PrismaTransactionOptions
|
||||||
|
): Promise<R | undefined> {
|
||||||
|
if (typeof fnOrName === "string") {
|
||||||
|
const fn = fnOrOptions as (prisma: PrismaTransactionClient) => Promise<R>;
|
||||||
|
|
||||||
|
return await transac(
|
||||||
|
prisma,
|
||||||
|
(client) => fn(client),
|
||||||
|
(error) => {
|
||||||
|
logger.error("prisma.$transaction error", {
|
||||||
|
code: error.code,
|
||||||
|
meta: error.meta,
|
||||||
|
stack: error.stack,
|
||||||
|
message: error.message,
|
||||||
|
name: error.name,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return transac(
|
||||||
|
prisma,
|
||||||
|
fnOrName,
|
||||||
|
(error) => {
|
||||||
|
logger.error("prisma.$transaction error", {
|
||||||
|
code: error.code,
|
||||||
|
meta: error.meta,
|
||||||
|
stack: error.stack,
|
||||||
|
message: error.message,
|
||||||
|
name: error.name,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
typeof fnOrOptions === "function" ? undefined : fnOrOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Prisma };
|
||||||
|
|
||||||
|
export const prisma = singleton("prisma", getClient);
|
||||||
|
|
||||||
|
export const $replica: PrismaReplicaClient = singleton(
|
||||||
|
"replica",
|
||||||
|
() => getReplicaClient() ?? prisma
|
||||||
|
);
|
||||||
|
|
||||||
|
function getClient() {
|
||||||
|
const { DATABASE_URL } = process.env;
|
||||||
|
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
|
||||||
|
|
||||||
|
const databaseUrl = extendQueryParams(DATABASE_URL, {
|
||||||
|
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
|
||||||
|
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
|
||||||
|
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🔌 setting up prisma client to ${redactUrlSecrets(databaseUrl)}`);
|
||||||
|
|
||||||
|
const client = new PrismaClient({
|
||||||
|
datasources: {
|
||||||
|
db: {
|
||||||
|
url: databaseUrl.href,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// @ts-expect-error
|
||||||
|
log: [
|
||||||
|
{
|
||||||
|
emit: "stdout",
|
||||||
|
level: "error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emit: "stdout",
|
||||||
|
level: "info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emit: "stdout",
|
||||||
|
level: "warn",
|
||||||
|
},
|
||||||
|
].concat(
|
||||||
|
process.env.VERBOSE_PRISMA_LOGS === "1"
|
||||||
|
? [
|
||||||
|
{ emit: "event", level: "query" },
|
||||||
|
{ emit: "stdout", level: "query" },
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// connect eagerly
|
||||||
|
client.$connect();
|
||||||
|
|
||||||
|
console.log(`🔌 prisma client connected`);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReplicaClient() {
|
||||||
|
if (!env.DATABASE_READ_REPLICA_URL) {
|
||||||
|
console.log(`🔌 No database replica, using the regular client`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replicaUrl = extendQueryParams(env.DATABASE_READ_REPLICA_URL, {
|
||||||
|
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
|
||||||
|
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
|
||||||
|
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🔌 setting up read replica connection to ${redactUrlSecrets(replicaUrl)}`);
|
||||||
|
|
||||||
|
const replicaClient = new PrismaClient({
|
||||||
|
datasources: {
|
||||||
|
db: {
|
||||||
|
url: replicaUrl.href,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// @ts-expect-error
|
||||||
|
log: [
|
||||||
|
{
|
||||||
|
emit: "stdout",
|
||||||
|
level: "error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emit: "stdout",
|
||||||
|
level: "info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emit: "stdout",
|
||||||
|
level: "warn",
|
||||||
|
},
|
||||||
|
].concat(
|
||||||
|
process.env.VERBOSE_PRISMA_LOGS === "1"
|
||||||
|
? [
|
||||||
|
{ emit: "event", level: "query" },
|
||||||
|
{ emit: "stdout", level: "query" },
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// connect eagerly
|
||||||
|
replicaClient.$connect();
|
||||||
|
|
||||||
|
console.log(`🔌 read replica connected`);
|
||||||
|
|
||||||
|
return replicaClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extendQueryParams(hrefOrUrl: string | URL, queryParams: Record<string, string>) {
|
||||||
|
const url = new URL(hrefOrUrl);
|
||||||
|
const query = url.searchParams;
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(queryParams)) {
|
||||||
|
query.set(key, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
url.search = query.toString();
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactUrlSecrets(hrefOrUrl: string | URL) {
|
||||||
|
const url = new URL(hrefOrUrl);
|
||||||
|
url.password = "";
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PrismaClient } from "@echo/database";
|
||||||
|
|
||||||
|
export const PrismaErrorSchema = z.object({
|
||||||
|
code: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function getDatabaseSchema() {
|
||||||
|
if (!isValidDatabaseUrl(env.DATABASE_URL)) {
|
||||||
|
throw new Error("Invalid Database URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseUrl = new URL(env.DATABASE_URL);
|
||||||
|
const schemaFromSearchParam = databaseUrl.searchParams.get("schema");
|
||||||
|
|
||||||
|
if (!schemaFromSearchParam) {
|
||||||
|
console.debug("❗ database schema unspecified, will default to `public` schema");
|
||||||
|
return "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemaFromSearchParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATABASE_SCHEMA = singleton("DATABASE_SCHEMA", getDatabaseSchema);
|
||||||
|
|
||||||
|
export const sqlDatabaseSchema = Prisma.sql([`${DATABASE_SCHEMA}`]);
|
||||||
@ -22,8 +22,13 @@ const EnvironmentSchema = z.object({
|
|||||||
SESSION_SECRET: z.string(),
|
SESSION_SECRET: z.string(),
|
||||||
|
|
||||||
APP_ENV: z.string().default(process.env.NODE_ENV),
|
APP_ENV: z.string().default(process.env.NODE_ENV),
|
||||||
|
LOGIN_ORIGIN: z.string().default("http://localhost:5173"),
|
||||||
APP_ORIGIN: z.string().default("http://localhost:5173"),
|
APP_ORIGIN: z.string().default("http://localhost:5173"),
|
||||||
POSTHOG_PROJECT_KEY: z.string().default(""),
|
POSTHOG_PROJECT_KEY: z.string().default(""),
|
||||||
|
|
||||||
|
// google auth
|
||||||
|
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
|
||||||
|
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||||
|
|||||||
26
apps/webapp/app/hooks/useChanged.ts
Normal file
26
apps/webapp/app/hooks/useChanged.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/** Call a function when the id of the item changes */
|
||||||
|
export function useChanged<T extends { id: string }>(
|
||||||
|
getItem: () => T | undefined,
|
||||||
|
action: (item: T | undefined) => void,
|
||||||
|
sendInitialUndefined = true
|
||||||
|
) {
|
||||||
|
const previousItemId = useRef<string | undefined>();
|
||||||
|
const item = getItem();
|
||||||
|
|
||||||
|
//when the value changes, call the action
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousItemId.current !== item?.id) {
|
||||||
|
action(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousItemId.current = item?.id;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
//if sendInitialUndefined is true, call the action when the component first renders
|
||||||
|
useEffect(() => {
|
||||||
|
if (item !== undefined || sendInitialUndefined === false) return;
|
||||||
|
action(item);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
49
apps/webapp/app/hooks/usePostHog.ts
Normal file
49
apps/webapp/app/hooks/usePostHog.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useLocation } from "@remix-run/react";
|
||||||
|
import posthog from "posthog-js";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { useOptionalUser, useUserChanged } from "./useUser";
|
||||||
|
|
||||||
|
export const usePostHog = (apiKey?: string, logging = false, debug = false): void => {
|
||||||
|
const postHogInitialized = useRef(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const user = useOptionalUser();
|
||||||
|
|
||||||
|
//start PostHog once
|
||||||
|
useEffect(() => {
|
||||||
|
if (apiKey === undefined || apiKey === "") return;
|
||||||
|
if (postHogInitialized.current === true) return;
|
||||||
|
if (logging) console.log("Initializing PostHog");
|
||||||
|
posthog.init(apiKey, {
|
||||||
|
api_host: "https://eu.posthog.com",
|
||||||
|
opt_in_site_apps: true,
|
||||||
|
debug,
|
||||||
|
loaded: function (posthog) {
|
||||||
|
if (logging) console.log("PostHog loaded");
|
||||||
|
if (user !== undefined) {
|
||||||
|
if (logging) console.log("Loaded: Identifying user", user);
|
||||||
|
posthog.identify(user.id, { email: user.email });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
postHogInitialized.current = true;
|
||||||
|
}, [apiKey, logging, user]);
|
||||||
|
|
||||||
|
useUserChanged((user) => {
|
||||||
|
if (postHogInitialized.current === false) return;
|
||||||
|
if (logging) console.log("User changed");
|
||||||
|
if (user) {
|
||||||
|
if (logging) console.log("Identifying user", user);
|
||||||
|
posthog.identify(user.id, { email: user.email });
|
||||||
|
} else {
|
||||||
|
if (logging) console.log("Resetting user");
|
||||||
|
posthog.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//page view
|
||||||
|
useEffect(() => {
|
||||||
|
if (postHogInitialized.current === false) return;
|
||||||
|
posthog.capture("$pageview");
|
||||||
|
}, [location, logging]);
|
||||||
|
};
|
||||||
44
apps/webapp/app/hooks/useTypedMatchData.ts
Normal file
44
apps/webapp/app/hooks/useTypedMatchData.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { type UIMatch, useMatches } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
type RemixSerializedType,
|
||||||
|
type UseDataFunctionReturn,
|
||||||
|
deserializeRemix,
|
||||||
|
} from "remix-typedjson";
|
||||||
|
|
||||||
|
type AppData = any;
|
||||||
|
|
||||||
|
function useTypedDataFromMatches<T = AppData>({
|
||||||
|
id,
|
||||||
|
matches,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
matches: UIMatch[];
|
||||||
|
}): UseDataFunctionReturn<T> | undefined {
|
||||||
|
const match = matches.find((m) => m.id === id);
|
||||||
|
return useTypedMatchData<T>(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTypedMatchesData<T = AppData>({
|
||||||
|
id,
|
||||||
|
matches,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
matches?: UIMatch[];
|
||||||
|
}): UseDataFunctionReturn<T> | undefined {
|
||||||
|
if (!matches) {
|
||||||
|
matches = useMatches();
|
||||||
|
}
|
||||||
|
|
||||||
|
return useTypedDataFromMatches<T>({ id, matches });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTypedMatchData<T = AppData>(
|
||||||
|
match: UIMatch | undefined
|
||||||
|
): UseDataFunctionReturn<T> | undefined {
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return deserializeRemix<T>(match.data as RemixSerializedType<T>) as
|
||||||
|
| UseDataFunctionReturn<T>
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
43
apps/webapp/app/hooks/useUser.ts
Normal file
43
apps/webapp/app/hooks/useUser.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { type UIMatch } from "@remix-run/react";
|
||||||
|
import type { User } from "~/models/user.server";
|
||||||
|
import { type loader } from "~/root";
|
||||||
|
import { useChanged } from "./useChanged";
|
||||||
|
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||||
|
|
||||||
|
export function useIsImpersonating(matches?: UIMatch[]) {
|
||||||
|
const data = useTypedMatchesData<typeof orgLoader>({
|
||||||
|
id: "routes/_app.workspace.$workspaceSlug",
|
||||||
|
matches,
|
||||||
|
});
|
||||||
|
return data?.isImpersonating === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
|
||||||
|
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||||
|
id: "root",
|
||||||
|
matches,
|
||||||
|
});
|
||||||
|
|
||||||
|
return routeMatch?.user ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser(matches?: UIMatch[]): User {
|
||||||
|
const maybeUser = useOptionalUser(matches);
|
||||||
|
if (!maybeUser) {
|
||||||
|
throw new Error(
|
||||||
|
"No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return maybeUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserChanged(callback: (user: User | undefined) => void) {
|
||||||
|
useChanged(useOptionalUser, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHasAdminAccess(matches?: UIMatch[]): boolean {
|
||||||
|
const user = useOptionalUser(matches);
|
||||||
|
const isImpersonating = useIsImpersonating(matches);
|
||||||
|
|
||||||
|
return Boolean(user?.admin) || isImpersonating;
|
||||||
|
}
|
||||||
@ -1,6 +1,11 @@
|
|||||||
import { json, type Session , createCookieSessionStorage } from "@remix-run/node";
|
import {
|
||||||
|
json,
|
||||||
|
type Session,
|
||||||
|
createCookieSessionStorage,
|
||||||
|
} from "@remix-run/node";
|
||||||
import { redirect } from "remix-typedjson";
|
import { redirect } from "remix-typedjson";
|
||||||
import { env } from "~/env.server";
|
import { env } from "~/env.server";
|
||||||
|
import { createThemeSessionResolver } from "remix-themes";
|
||||||
|
|
||||||
export type ToastMessage = {
|
export type ToastMessage = {
|
||||||
message: string;
|
message: string;
|
||||||
@ -29,7 +34,7 @@ export const { commitSession, getSession } = createCookieSessionStorage({
|
|||||||
export function setSuccessMessage(
|
export function setSuccessMessage(
|
||||||
session: Session,
|
session: Session,
|
||||||
message: string,
|
message: string,
|
||||||
options?: ToastMessageOptions
|
options?: ToastMessageOptions,
|
||||||
) {
|
) {
|
||||||
session.flash("toastMessage", {
|
session.flash("toastMessage", {
|
||||||
message,
|
message,
|
||||||
@ -40,7 +45,11 @@ export function setSuccessMessage(
|
|||||||
} as ToastMessage);
|
} as ToastMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setErrorMessage(session: Session, message: string, options?: ToastMessageOptions) {
|
export function setErrorMessage(
|
||||||
|
session: Session,
|
||||||
|
message: string,
|
||||||
|
options?: ToastMessageOptions,
|
||||||
|
) {
|
||||||
session.flash("toastMessage", {
|
session.flash("toastMessage", {
|
||||||
message,
|
message,
|
||||||
type: "error",
|
type: "error",
|
||||||
@ -53,7 +62,7 @@ export function setErrorMessage(session: Session, message: string, options?: Toa
|
|||||||
export async function setRequestErrorMessage(
|
export async function setRequestErrorMessage(
|
||||||
request: Request,
|
request: Request,
|
||||||
message: string,
|
message: string,
|
||||||
options?: ToastMessageOptions
|
options?: ToastMessageOptions,
|
||||||
) {
|
) {
|
||||||
const session = await getSession(request.headers.get("cookie"));
|
const session = await getSession(request.headers.get("cookie"));
|
||||||
|
|
||||||
@ -65,7 +74,7 @@ export async function setRequestErrorMessage(
|
|||||||
export async function setRequestSuccessMessage(
|
export async function setRequestSuccessMessage(
|
||||||
request: Request,
|
request: Request,
|
||||||
message: string,
|
message: string,
|
||||||
options?: ToastMessageOptions
|
options?: ToastMessageOptions,
|
||||||
) {
|
) {
|
||||||
const session = await getSession(request.headers.get("cookie"));
|
const session = await getSession(request.headers.get("cookie"));
|
||||||
|
|
||||||
@ -86,7 +95,7 @@ export async function jsonWithSuccessMessage(
|
|||||||
data: any,
|
data: any,
|
||||||
request: Request,
|
request: Request,
|
||||||
message: string,
|
message: string,
|
||||||
options?: ToastMessageOptions
|
options?: ToastMessageOptions,
|
||||||
) {
|
) {
|
||||||
const session = await getSession(request.headers.get("cookie"));
|
const session = await getSession(request.headers.get("cookie"));
|
||||||
|
|
||||||
@ -105,7 +114,7 @@ export async function jsonWithErrorMessage(
|
|||||||
data: any,
|
data: any,
|
||||||
request: Request,
|
request: Request,
|
||||||
message: string,
|
message: string,
|
||||||
options?: ToastMessageOptions
|
options?: ToastMessageOptions,
|
||||||
) {
|
) {
|
||||||
const session = await getSession(request.headers.get("cookie"));
|
const session = await getSession(request.headers.get("cookie"));
|
||||||
|
|
||||||
@ -124,7 +133,7 @@ export async function redirectWithSuccessMessage(
|
|||||||
path: string,
|
path: string,
|
||||||
request: Request,
|
request: Request,
|
||||||
message: string,
|
message: string,
|
||||||
options?: ToastMessageOptions
|
options?: ToastMessageOptions,
|
||||||
) {
|
) {
|
||||||
const session = await getSession(request.headers.get("cookie"));
|
const session = await getSession(request.headers.get("cookie"));
|
||||||
|
|
||||||
@ -143,7 +152,7 @@ export async function redirectWithErrorMessage(
|
|||||||
path: string,
|
path: string,
|
||||||
request: Request,
|
request: Request,
|
||||||
message: string,
|
message: string,
|
||||||
options?: ToastMessageOptions
|
options?: ToastMessageOptions,
|
||||||
) {
|
) {
|
||||||
const session = await getSession(request.headers.get("cookie"));
|
const session = await getSession(request.headers.get("cookie"));
|
||||||
|
|
||||||
@ -161,7 +170,7 @@ export async function redirectWithErrorMessage(
|
|||||||
export async function redirectBackWithErrorMessage(
|
export async function redirectBackWithErrorMessage(
|
||||||
request: Request,
|
request: Request,
|
||||||
message: string,
|
message: string,
|
||||||
options?: ToastMessageOptions
|
options?: ToastMessageOptions,
|
||||||
) {
|
) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
return redirectWithErrorMessage(url.pathname, request, message, options);
|
return redirectWithErrorMessage(url.pathname, request, message, options);
|
||||||
@ -170,7 +179,7 @@ export async function redirectBackWithErrorMessage(
|
|||||||
export async function redirectBackWithSuccessMessage(
|
export async function redirectBackWithSuccessMessage(
|
||||||
request: Request,
|
request: Request,
|
||||||
message: string,
|
message: string,
|
||||||
options?: ToastMessageOptions
|
options?: ToastMessageOptions,
|
||||||
) {
|
) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
return redirectWithSuccessMessage(url.pathname, request, message, options);
|
return redirectWithSuccessMessage(url.pathname, request, message, options);
|
||||||
|
|||||||
155
apps/webapp/app/models/user.server.ts
Normal file
155
apps/webapp/app/models/user.server.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import type { Prisma, User } from "@echo/database";
|
||||||
|
import type { GoogleProfile } from "remix-auth-google";
|
||||||
|
import { prisma } from "~/db.server";
|
||||||
|
export type { User } from "@echo/database";
|
||||||
|
|
||||||
|
type FindOrCreateGoogle = {
|
||||||
|
authenticationMethod: "GOOGLE";
|
||||||
|
email: User["email"];
|
||||||
|
authenticationProfile: GoogleProfile;
|
||||||
|
authenticationExtraParams: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindOrCreateUser = FindOrCreateGoogle;
|
||||||
|
|
||||||
|
type LoggedInUser = {
|
||||||
|
user: User;
|
||||||
|
isNewUser: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedInUser> {
|
||||||
|
return findOrCreateGoogleUser(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findOrCreateGoogleUser({
|
||||||
|
email,
|
||||||
|
authenticationProfile,
|
||||||
|
authenticationExtraParams,
|
||||||
|
}: FindOrCreateGoogle): Promise<LoggedInUser> {
|
||||||
|
const name = authenticationProfile._json.name;
|
||||||
|
let avatarUrl: string | undefined = undefined;
|
||||||
|
if (authenticationProfile.photos[0]) {
|
||||||
|
avatarUrl = authenticationProfile.photos[0].value;
|
||||||
|
}
|
||||||
|
const displayName = authenticationProfile.displayName;
|
||||||
|
const authProfile = authenticationProfile
|
||||||
|
? (authenticationProfile as unknown as Prisma.JsonObject)
|
||||||
|
: undefined;
|
||||||
|
const authExtraParams = authenticationExtraParams
|
||||||
|
? (authenticationExtraParams as unknown as Prisma.JsonObject)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const authIdentifier = `github:${authenticationProfile.id}`;
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
authIdentifier,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingEmailUser = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingEmailUser && !existingUser) {
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
authenticationProfile: authProfile,
|
||||||
|
authenticationExtraParams: authExtraParams,
|
||||||
|
avatarUrl,
|
||||||
|
authIdentifier,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isNewUser: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingEmailUser && existingUser) {
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: existingUser.id,
|
||||||
|
},
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isNewUser: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: {
|
||||||
|
authIdentifier,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
authenticationProfile: authProfile,
|
||||||
|
authenticationExtraParams: authExtraParams,
|
||||||
|
name,
|
||||||
|
avatarUrl,
|
||||||
|
displayName,
|
||||||
|
authIdentifier,
|
||||||
|
email,
|
||||||
|
authenticationMethod: "GOOGLE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isNewUser: !existingUser,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: User["id"]) {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: User["email"]) {
|
||||||
|
return prisma.user.findUnique({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
marketingEmails,
|
||||||
|
referralSource,
|
||||||
|
}: Pick<User, "id" | "name" | "email"> & {
|
||||||
|
marketingEmails?: boolean;
|
||||||
|
referralSource?: string;
|
||||||
|
}) {
|
||||||
|
return prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name, email, marketingEmails, referralSource, confirmedBasicDetails: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantUserCloudAccess({ id, inviteCode }: { id: string; inviteCode: string }) {
|
||||||
|
return prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
InvitationCode: {
|
||||||
|
connect: {
|
||||||
|
code: inviteCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,47 +1,73 @@
|
|||||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
|
import {
|
||||||
import type { LinksFunction, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
Links,
|
||||||
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
|
LiveReload,
|
||||||
|
Meta,
|
||||||
|
Outlet,
|
||||||
|
Scripts,
|
||||||
|
ScrollRestoration,
|
||||||
|
useLoaderData,
|
||||||
|
} from "@remix-run/react";
|
||||||
|
import type {
|
||||||
|
LinksFunction,
|
||||||
|
LoaderFunctionArgs,
|
||||||
|
MetaFunction,
|
||||||
|
} from "@remix-run/node";
|
||||||
|
import {
|
||||||
|
type UseDataFunctionReturn,
|
||||||
|
typedjson,
|
||||||
|
useTypedLoaderData,
|
||||||
|
} from "remix-typedjson";
|
||||||
|
|
||||||
|
import styles from "./tailwind.css?url";
|
||||||
|
|
||||||
import tailwindStylesheetUrl from "~/tailwind.css";
|
|
||||||
import { appEnvTitleTag } from "./utils";
|
import { appEnvTitleTag } from "./utils";
|
||||||
import { commitSession, getSession, type ToastMessage } from "./models/message.server";
|
import {
|
||||||
|
commitSession,
|
||||||
|
getSession,
|
||||||
|
type ToastMessage,
|
||||||
|
} from "./models/message.server";
|
||||||
import { env } from "./env.server";
|
import { env } from "./env.server";
|
||||||
|
import { getUser } from "./services/session.server";
|
||||||
|
import { usePostHog } from "./hooks/usePostHog";
|
||||||
|
import {
|
||||||
|
AppContainer,
|
||||||
|
MainCenteredContainer,
|
||||||
|
} from "./components/layout/AppLayout";
|
||||||
|
import { RouteErrorDisplay } from "./components/ErrorDisplay";
|
||||||
|
import { themeSessionResolver } from "./services/sessionStorage.server";
|
||||||
|
import {
|
||||||
|
PreventFlashOnWrongTheme,
|
||||||
|
ThemeProvider,
|
||||||
|
useTheme,
|
||||||
|
} from "remix-themes";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const links: LinksFunction = () => [
|
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
|
||||||
{
|
|
||||||
rel: "preconnect",
|
|
||||||
href: "https://fonts.gstatic.com",
|
|
||||||
crossOrigin: "anonymous",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: "stylesheet",
|
|
||||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
|
||||||
},
|
|
||||||
{ rel: "stylesheet", href: tailwindStylesheetUrl },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const session = await getSession(request.headers.get("cookie"));
|
const session = await getSession(request.headers.get("cookie"));
|
||||||
const toastMessage = session.get("toastMessage") as ToastMessage;
|
const toastMessage = session.get("toastMessage") as ToastMessage;
|
||||||
|
const { getTheme } = await themeSessionResolver(request);
|
||||||
|
|
||||||
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
|
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
|
||||||
|
|
||||||
return typedjson(
|
return typedjson(
|
||||||
{
|
{
|
||||||
user: await getUser(request),
|
|
||||||
toastMessage,
|
toastMessage,
|
||||||
|
theme: getTheme(),
|
||||||
posthogProjectKey,
|
posthogProjectKey,
|
||||||
appEnv: env.APP_ENV,
|
appEnv: env.APP_ENV,
|
||||||
appOrigin: env.APP_ORIGIN,
|
appOrigin: env.APP_ORIGIN,
|
||||||
},
|
},
|
||||||
{ headers: { "Set-Cookie": await commitSession(session) } }
|
{ headers: { "Set-Cookie": await commitSession(session) } },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const meta: MetaFunction = ({ data }) => {
|
export const meta: MetaFunction = ({ data }) => {
|
||||||
const typedData = data as UseDataFunctionReturn<typeof loader>;
|
const typedData = data as UseDataFunctionReturn<typeof loader>;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ title: `Echo${appEnvTitleTag(typedData.appEnv)}` },
|
{ title: `Echo${typedData && appEnvTitleTag(typedData.appEnv)}` },
|
||||||
{
|
{
|
||||||
name: "viewport",
|
name: "viewport",
|
||||||
content: "width=1024, initial-scale=1",
|
content: "width=1024, initial-scale=1",
|
||||||
@ -49,31 +75,70 @@ export const meta: MetaFunction = ({ data }) => {
|
|||||||
{
|
{
|
||||||
name: "robots",
|
name: "robots",
|
||||||
content:
|
content:
|
||||||
typeof window === "undefined" || window.location.hostname !== "echo.mysigma.ai"
|
typeof window === "undefined" ||
|
||||||
|
window.location.hostname !== "echo.mysigma.ai"
|
||||||
? "noindex, nofollow"
|
? "noindex, nofollow"
|
||||||
: "index, follow",
|
: "index, follow",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function ErrorBoundary() {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<>
|
||||||
<head>
|
<html lang="en" className="h-full">
|
||||||
<meta charSet="utf-8" />
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta charSet="utf-8" />
|
||||||
<Meta />
|
|
||||||
<Links />
|
<Meta />
|
||||||
</head>
|
<Links />
|
||||||
<body>
|
</head>
|
||||||
{children}
|
<body className="bg-background h-full overflow-hidden">
|
||||||
<ScrollRestoration />
|
<AppContainer>
|
||||||
<Scripts />
|
<MainCenteredContainer>
|
||||||
</body>
|
<RouteErrorDisplay />
|
||||||
</html>
|
</MainCenteredContainer>
|
||||||
|
</AppContainer>
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
function App() {
|
||||||
return <Outlet />;
|
const { posthogProjectKey } = useTypedLoaderData<typeof loader>();
|
||||||
|
usePostHog(posthogProjectKey);
|
||||||
|
const [theme] = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<html lang="en" className={clsx(theme, "h-full")}>
|
||||||
|
<head>
|
||||||
|
<Meta />
|
||||||
|
<Links />
|
||||||
|
<PreventFlashOnWrongTheme ssrTheme={Boolean(theme)} />
|
||||||
|
</head>
|
||||||
|
<body className="bg-background h-full overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
<ScrollRestoration />
|
||||||
|
|
||||||
|
<Scripts />
|
||||||
|
<LiveReload />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap your app with ThemeProvider.
|
||||||
|
// `specifiedTheme` is the stored theme in the session storage.
|
||||||
|
// `themeAction` is the action name that's used to change the theme in the session storage.
|
||||||
|
export default function AppWithProviders() {
|
||||||
|
const data = useLoaderData<typeof loader>();
|
||||||
|
return (
|
||||||
|
<ThemeProvider specifiedTheme={data.theme} themeAction="/action/set-theme">
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
4
apps/webapp/app/routes/action.set-theme.ts
Normal file
4
apps/webapp/app/routes/action.set-theme.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { createThemeAction } from "remix-themes";
|
||||||
|
import { themeSessionResolver } from "~/services/sessionStorage.server";
|
||||||
|
|
||||||
|
export const action = createThemeAction(themeSessionResolver);
|
||||||
31
apps/webapp/app/routes/login.tsx
Normal file
31
apps/webapp/app/routes/login.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { useNavigation } from "@remix-run/react";
|
||||||
|
import { typedjson } from "remix-typedjson";
|
||||||
|
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
|
||||||
|
import { authenticator } from "~/services/auth.server";
|
||||||
|
import {
|
||||||
|
commitSession,
|
||||||
|
getUserSession,
|
||||||
|
} from "~/services/sessionStorage.server";
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
await authenticator.isAuthenticated(request, {
|
||||||
|
successRedirect: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = await getUserSession(request);
|
||||||
|
|
||||||
|
return typedjson({
|
||||||
|
headers: { "Set-Cookie": await commitSession(session) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoginPageLayout>
|
||||||
|
<h2>Lohin</h2>
|
||||||
|
</LoginPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/webapp/app/services/apiRateLimit.server.ts
Normal file
64
apps/webapp/app/services/apiRateLimit.server.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { env } from "~/env.server";
|
||||||
|
import { authenticateAuthorizationHeader } from "./apiAuth.server";
|
||||||
|
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
|
||||||
|
import { Duration } from "./rateLimiter.server";
|
||||||
|
|
||||||
|
export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||||
|
redis: {
|
||||||
|
port: env.RATE_LIMIT_REDIS_PORT,
|
||||||
|
host: env.RATE_LIMIT_REDIS_HOST,
|
||||||
|
username: env.RATE_LIMIT_REDIS_USERNAME,
|
||||||
|
password: env.RATE_LIMIT_REDIS_PASSWORD,
|
||||||
|
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
|
||||||
|
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
|
||||||
|
},
|
||||||
|
keyPrefix: "api",
|
||||||
|
defaultLimiter: {
|
||||||
|
type: "tokenBucket",
|
||||||
|
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
|
||||||
|
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||||
|
maxTokens: env.API_RATE_LIMIT_MAX,
|
||||||
|
},
|
||||||
|
limiterCache: {
|
||||||
|
fresh: 60_000 * 10, // Data is fresh for 10 minutes
|
||||||
|
stale: 60_000 * 20, // Date is stale after 20 minutes
|
||||||
|
},
|
||||||
|
limiterConfigOverride: async (authorizationValue) => {
|
||||||
|
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
|
||||||
|
allowPublicKey: true,
|
||||||
|
allowJWT: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authenticatedEnv || !authenticatedEnv.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authenticatedEnv.type === "PUBLIC_JWT") {
|
||||||
|
return {
|
||||||
|
type: "fixedWindow",
|
||||||
|
window: env.API_RATE_LIMIT_JWT_WINDOW,
|
||||||
|
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pathMatchers: [/^\/api/],
|
||||||
|
// Allow /api/v1/tasks/:id/callback/:secret
|
||||||
|
pathWhiteList: [
|
||||||
|
"/api/internal/stripe_webhooks",
|
||||||
|
"/api/v1/authorization-code",
|
||||||
|
"/api/v1/token",
|
||||||
|
"/api/v1/usage/ingest",
|
||||||
|
"/api/v1/timezones",
|
||||||
|
"/api/v1/usage/ingest",
|
||||||
|
"/api/v1/auth/jwt/claims",
|
||||||
|
],
|
||||||
|
log: {
|
||||||
|
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
|
||||||
|
requests: env.API_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1",
|
||||||
|
limiter: env.API_RATE_LIMIT_LIMITER_LOGS_ENABLED === "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RateLimitMiddleware = ReturnType<typeof authorizationRateLimitMiddleware>;
|
||||||
20
apps/webapp/app/services/auth.server.ts
Normal file
20
apps/webapp/app/services/auth.server.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Authenticator } from "remix-auth";
|
||||||
|
import type { AuthUser } from "./authUser";
|
||||||
|
|
||||||
|
import { addGoogleStrategy } from "./googleAuth.server";
|
||||||
|
import { sessionStorage } from "./sessionStorage.server";
|
||||||
|
import { env } from "~/env.server";
|
||||||
|
|
||||||
|
// Create an instance of the authenticator, pass a generic with what
|
||||||
|
// strategies will return and will store in the session
|
||||||
|
const authenticator = new Authenticator<AuthUser>(sessionStorage);
|
||||||
|
|
||||||
|
const isGoogleAuthSupported =
|
||||||
|
typeof env.AUTH_GOOGLE_CLIENT_ID === "string" &&
|
||||||
|
typeof env.AUTH_GOOGLE_CLIENT_SECRET === "string";
|
||||||
|
|
||||||
|
if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
|
||||||
|
addGoogleStrategy(authenticator, env.AUTH_GOOGLE_CLIENT_ID, env.AUTH_GOOGLE_CLIENT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { authenticator, isGoogleAuthSupported };
|
||||||
3
apps/webapp/app/services/authUser.ts
Normal file
3
apps/webapp/app/services/authUser.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export type AuthUser = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
54
apps/webapp/app/services/googleAuth.server.ts
Normal file
54
apps/webapp/app/services/googleAuth.server.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import type { Authenticator } from "remix-auth";
|
||||||
|
import { GoogleStrategy } from "remix-auth-google";
|
||||||
|
import { env } from "~/env.server";
|
||||||
|
import { findOrCreateUser } from "~/models/user.server";
|
||||||
|
import type { AuthUser } from "./authUser";
|
||||||
|
import { postAuthentication } from "./postAuth.server";
|
||||||
|
import { logger } from "./logger.service";
|
||||||
|
|
||||||
|
export function addGoogleStrategy(
|
||||||
|
authenticator: Authenticator<AuthUser>,
|
||||||
|
clientID: string,
|
||||||
|
clientSecret: string
|
||||||
|
) {
|
||||||
|
const googleStrategy = new GoogleStrategy(
|
||||||
|
{
|
||||||
|
clientID,
|
||||||
|
clientSecret,
|
||||||
|
callbackURL: `${env.LOGIN_ORIGIN}/auth/google/callback`,
|
||||||
|
},
|
||||||
|
async ({ extraParams, profile }) => {
|
||||||
|
const emails = profile.emails;
|
||||||
|
|
||||||
|
if (!emails) {
|
||||||
|
throw new Error("Google login requires an email address");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug("Google login", {
|
||||||
|
emails,
|
||||||
|
profile,
|
||||||
|
extraParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { user, isNewUser } = await findOrCreateUser({
|
||||||
|
email: emails[0].value,
|
||||||
|
authenticationMethod: "GOOGLE",
|
||||||
|
authenticationProfile: profile,
|
||||||
|
authenticationExtraParams: extraParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
await postAuthentication({ user, isNewUser, loginMethod: "GOOGLE" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticator.use(googleStrategy);
|
||||||
|
}
|
||||||
20
apps/webapp/app/services/httpAsyncStorage.server.ts
Normal file
20
apps/webapp/app/services/httpAsyncStorage.server.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
|
||||||
|
export type HttpLocalStorage = {
|
||||||
|
requestId: string;
|
||||||
|
path: string;
|
||||||
|
host: string;
|
||||||
|
method: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const httpLocalStorage = new AsyncLocalStorage<HttpLocalStorage>();
|
||||||
|
|
||||||
|
export type RunWithHttpContextFunction = <T>(context: HttpLocalStorage, fn: () => T) => T;
|
||||||
|
|
||||||
|
export function runWithHttpContext<T>(context: HttpLocalStorage, fn: () => T): T {
|
||||||
|
return httpLocalStorage.run(context, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHttpContext(): HttpLocalStorage | undefined {
|
||||||
|
return httpLocalStorage.getStore();
|
||||||
|
}
|
||||||
44
apps/webapp/app/services/impersonation.server.ts
Normal file
44
apps/webapp/app/services/impersonation.server.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { createCookieSessionStorage, type Session } from "@remix-run/node";
|
||||||
|
import { env } from "~/env.server";
|
||||||
|
|
||||||
|
export const impersonationSessionStorage = createCookieSessionStorage({
|
||||||
|
cookie: {
|
||||||
|
name: "__impersonate", // use any name you want here
|
||||||
|
sameSite: "lax", // this helps with CSRF
|
||||||
|
path: "/", // remember to add this so the cookie will work in all routes
|
||||||
|
httpOnly: true, // for security reasons, make this cookie http only
|
||||||
|
secrets: [env.SESSION_SECRET],
|
||||||
|
secure: env.NODE_ENV === "production", // enable this in prod only
|
||||||
|
maxAge: 60 * 60 * 24, // 1 day
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getImpersonationSession(request: Request) {
|
||||||
|
return impersonationSessionStorage.getSession(request.headers.get("Cookie"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commitImpersonationSession(session: Session) {
|
||||||
|
return impersonationSessionStorage.commitSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImpersonationId(request: Request) {
|
||||||
|
const session = await getImpersonationSession(request);
|
||||||
|
|
||||||
|
return session.get("impersonatedUserId") as string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setImpersonationId(userId: string, request: Request) {
|
||||||
|
const session = await getImpersonationSession(request);
|
||||||
|
|
||||||
|
session.set("impersonatedUserId", userId);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearImpersonationId(request: Request) {
|
||||||
|
const session = await getImpersonationSession(request);
|
||||||
|
|
||||||
|
session.unset("impersonatedUserId");
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
296
apps/webapp/app/services/logger.service.ts
Normal file
296
apps/webapp/app/services/logger.service.ts
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import { sensitiveDataReplacer } from "./sensitiveDataReplacer";
|
||||||
|
import { AsyncLocalStorage } from "async_hooks";
|
||||||
|
import { getHttpContext } from "./httpAsyncStorage.server";
|
||||||
|
|
||||||
|
import { env } from "node:process";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { trace, context } from "@opentelemetry/api";
|
||||||
|
|
||||||
|
export type LogLevel = "log" | "error" | "warn" | "info" | "debug";
|
||||||
|
|
||||||
|
const logLevels: Array<LogLevel> = ["log", "error", "warn", "info", "debug"];
|
||||||
|
|
||||||
|
export class Logger {
|
||||||
|
#name: string;
|
||||||
|
readonly #level: number;
|
||||||
|
#filteredKeys: string[] = [];
|
||||||
|
#jsonReplacer?: (key: string, value: unknown) => unknown;
|
||||||
|
#additionalFields: () => Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
level: LogLevel = "info",
|
||||||
|
filteredKeys: string[] = [],
|
||||||
|
jsonReplacer?: (key: string, value: unknown) => unknown,
|
||||||
|
additionalFields?: () => Record<string, unknown>
|
||||||
|
) {
|
||||||
|
this.#name = name;
|
||||||
|
this.#level = logLevels.indexOf((env.APP_LOG_LEVEL ?? level) as LogLevel);
|
||||||
|
this.#filteredKeys = filteredKeys;
|
||||||
|
this.#jsonReplacer = createReplacer(jsonReplacer);
|
||||||
|
this.#additionalFields = additionalFields ?? (() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
child(fields: Record<string, unknown>) {
|
||||||
|
return new Logger(
|
||||||
|
this.#name,
|
||||||
|
logLevels[this.#level],
|
||||||
|
this.#filteredKeys,
|
||||||
|
this.#jsonReplacer,
|
||||||
|
() => ({ ...this.#additionalFields(), ...fields })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a new Logger instance with the same name and a new log level
|
||||||
|
// but filter out the keys from the log messages (at any level)
|
||||||
|
filter(...keys: string[]) {
|
||||||
|
return new Logger(this.#name, logLevels[this.#level], keys, this.#jsonReplacer);
|
||||||
|
}
|
||||||
|
|
||||||
|
static satisfiesLogLevel(logLevel: LogLevel, setLevel: LogLevel) {
|
||||||
|
return logLevels.indexOf(logLevel) <= logLevels.indexOf(setLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||||
|
if (this.#level < 0) return;
|
||||||
|
|
||||||
|
this.#structuredLog(console.log, message, "log", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||||
|
if (this.#level < 1) return;
|
||||||
|
|
||||||
|
this.#structuredLog(console.error, message, "error", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||||
|
if (this.#level < 2) return;
|
||||||
|
|
||||||
|
this.#structuredLog(console.warn, message, "warn", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||||
|
if (this.#level < 3) return;
|
||||||
|
|
||||||
|
this.#structuredLog(console.info, message, "info", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||||
|
if (this.#level < 4) return;
|
||||||
|
|
||||||
|
this.#structuredLog(console.debug, message, "debug", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
#structuredLog(
|
||||||
|
loggerFunction: (message: string, ...args: any[]) => void,
|
||||||
|
message: string,
|
||||||
|
level: string,
|
||||||
|
...args: Array<Record<string, unknown> | undefined>
|
||||||
|
) {
|
||||||
|
// Get the current context from trace if it exists
|
||||||
|
const currentSpan = trace.getSpan(context.active());
|
||||||
|
|
||||||
|
const structuredError = extractStructuredErrorFromArgs(...args);
|
||||||
|
const structuredMessage = extractStructuredMessageFromArgs(...args);
|
||||||
|
|
||||||
|
const structuredLog = {
|
||||||
|
...structureArgs(safeJsonClone(args) as Record<string, unknown>[], this.#filteredKeys),
|
||||||
|
...this.#additionalFields(),
|
||||||
|
...(structuredError ? { error: structuredError } : {}),
|
||||||
|
timestamp: new Date(),
|
||||||
|
name: this.#name,
|
||||||
|
message,
|
||||||
|
...(structuredMessage ? { $message: structuredMessage } : {}),
|
||||||
|
level,
|
||||||
|
traceId:
|
||||||
|
currentSpan && currentSpan.isRecording() ? currentSpan?.spanContext().traceId : undefined,
|
||||||
|
parentSpanId:
|
||||||
|
currentSpan && currentSpan.isRecording() ? currentSpan?.spanContext().spanId : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the span is not recording, and it's a debug log, mark it so we can filter it out when we forward it
|
||||||
|
if (currentSpan && !currentSpan.isRecording() && level === "debug") {
|
||||||
|
structuredLog.skipForwarding = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
loggerFunction(JSON.stringify(structuredLog, this.#jsonReplacer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if args is an error object
|
||||||
|
// Or if args contains an error object at the "error" key
|
||||||
|
// In both cases, return the error object as a structured error
|
||||||
|
function extractStructuredErrorFromArgs(...args: Array<Record<string, unknown> | undefined>) {
|
||||||
|
const error = args.find((arg) => arg instanceof Error) as Error | undefined;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
name: error.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const structuredError = args.find((arg) => arg?.error);
|
||||||
|
|
||||||
|
if (structuredError && structuredError.error instanceof Error) {
|
||||||
|
return {
|
||||||
|
message: structuredError.error.message,
|
||||||
|
stack: structuredError.error.stack,
|
||||||
|
name: structuredError.error.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractStructuredMessageFromArgs(...args: Array<Record<string, unknown> | undefined>) {
|
||||||
|
// Check to see if there is a `message` key in the args, and if so, return it
|
||||||
|
const structuredMessage = args.find((arg) => arg?.message);
|
||||||
|
|
||||||
|
if (structuredMessage) {
|
||||||
|
return structuredMessage.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReplacer(replacer?: (key: string, value: unknown) => unknown) {
|
||||||
|
return (key: string, value: unknown) => {
|
||||||
|
if (typeof value === "bigint") {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacer) {
|
||||||
|
return replacer(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replacer function for JSON.stringify that converts BigInts to strings
|
||||||
|
function bigIntReplacer(_key: string, value: unknown) {
|
||||||
|
if (typeof value === "bigint") {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonClone(obj: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(obj, bigIntReplacer));
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If args is has a single item that is an object, return that object
|
||||||
|
function structureArgs(args: Array<Record<string, unknown>>, filteredKeys: string[] = []) {
|
||||||
|
if (!args) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length === 1 && typeof args[0] === "object") {
|
||||||
|
return filterKeys(JSON.parse(JSON.stringify(args[0], bigIntReplacer)), filteredKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively filter out keys from an object, including nested objects, and arrays
|
||||||
|
function filterKeys(obj: unknown, keys: string[]): any {
|
||||||
|
if (typeof obj !== "object" || obj === null) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => filterKeys(item, keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredObj: any = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (keys.includes(key)) {
|
||||||
|
if (value) {
|
||||||
|
filteredObj[key] = `[filtered ${prettyPrintBytes(value)}]`;
|
||||||
|
} else {
|
||||||
|
filteredObj[key] = value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredObj[key] = filterKeys(value, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyPrintBytes(value: unknown): string {
|
||||||
|
if (env.NODE_ENV === "production") {
|
||||||
|
return "skipped size";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeInBytes = getSizeInBytes(value);
|
||||||
|
|
||||||
|
if (sizeInBytes < 1024) {
|
||||||
|
return `${sizeInBytes} bytes`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sizeInBytes < 1024 * 1024) {
|
||||||
|
return `${(sizeInBytes / 1024).toFixed(2)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sizeInBytes < 1024 * 1024 * 1024) {
|
||||||
|
return `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSizeInBytes(value: unknown) {
|
||||||
|
const jsonString = JSON.stringify(value);
|
||||||
|
|
||||||
|
return Buffer.byteLength(jsonString, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFieldsStore = new AsyncLocalStorage<Record<string, unknown>>();
|
||||||
|
|
||||||
|
export const logger = new Logger(
|
||||||
|
"webapp",
|
||||||
|
(process.env.APP_LOG_LEVEL ?? "debug") as LogLevel,
|
||||||
|
["examples", "output", "connectionString", "payload"],
|
||||||
|
sensitiveDataReplacer,
|
||||||
|
() => {
|
||||||
|
const fields = currentFieldsStore.getStore();
|
||||||
|
const httpContext = getHttpContext();
|
||||||
|
return { ...fields, http: httpContext };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const workerLogger = new Logger(
|
||||||
|
"worker",
|
||||||
|
(process.env.APP_LOG_LEVEL ?? "debug") as LogLevel,
|
||||||
|
["examples", "output", "connectionString"],
|
||||||
|
sensitiveDataReplacer,
|
||||||
|
() => {
|
||||||
|
const fields = currentFieldsStore.getStore();
|
||||||
|
return fields ? { ...fields } : {};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const socketLogger = new Logger(
|
||||||
|
"socket",
|
||||||
|
(process.env.APP_LOG_LEVEL ?? "debug") as LogLevel,
|
||||||
|
[],
|
||||||
|
sensitiveDataReplacer,
|
||||||
|
() => {
|
||||||
|
const fields = currentFieldsStore.getStore();
|
||||||
|
return fields ? { ...fields } : {};
|
||||||
|
}
|
||||||
|
);
|
||||||
14
apps/webapp/app/services/postAuth.server.ts
Normal file
14
apps/webapp/app/services/postAuth.server.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { User } from "~/models/user.server";
|
||||||
|
import { singleton } from "~/utils/singleton";
|
||||||
|
|
||||||
|
export async function postAuthentication({
|
||||||
|
user,
|
||||||
|
loginMethod,
|
||||||
|
isNewUser,
|
||||||
|
}: {
|
||||||
|
user: User;
|
||||||
|
loginMethod: User["authenticationMethod"];
|
||||||
|
isNewUser: boolean;
|
||||||
|
}) {
|
||||||
|
console.log(user);
|
||||||
|
}
|
||||||
33
apps/webapp/app/services/sensitiveDataReplacer.ts
Normal file
33
apps/webapp/app/services/sensitiveDataReplacer.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const RedactStringSchema = z.object({
|
||||||
|
__redactedString: z.literal(true),
|
||||||
|
strings: z.array(z.string()),
|
||||||
|
interpolations: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RedactString = z.infer<typeof RedactStringSchema>;
|
||||||
|
|
||||||
|
// Replaces redacted strings with "******".
|
||||||
|
// For example, this object: {"Authorization":{"__redactedString":true,"strings":["Bearer ",""],"interpolations":["sk-1234"]}}
|
||||||
|
// Would get stringified like so: {"Authorization": "Bearer ******"}
|
||||||
|
export function sensitiveDataReplacer(key: string, value: any): any {
|
||||||
|
if (typeof value === "object" && value !== null && value.__redactedString === true) {
|
||||||
|
return redactString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactString(value: RedactString) {
|
||||||
|
let result = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < value.strings.length; i++) {
|
||||||
|
result += value.strings[i];
|
||||||
|
if (i < value.interpolations.length) {
|
||||||
|
result += "********";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { redirect } from "@remix-run/node";
|
||||||
|
import { getUserById } from "~/models/user.server";
|
||||||
|
import { authenticator } from "./auth.server";
|
||||||
|
import { getImpersonationId } from "./impersonation.server";
|
||||||
|
|
||||||
|
export async function getUserId(request: Request): Promise<string | undefined> {
|
||||||
|
const impersonatedUserId = await getImpersonationId(request);
|
||||||
|
|
||||||
|
if (impersonatedUserId) return impersonatedUserId;
|
||||||
|
|
||||||
|
let authUser = await authenticator.isAuthenticated(request);
|
||||||
|
return authUser?.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(request: Request) {
|
||||||
|
const userId = await getUserId(request);
|
||||||
|
if (userId === undefined) return null;
|
||||||
|
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
if (user) return user;
|
||||||
|
|
||||||
|
throw await logout(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(request: Request) {
|
||||||
|
return redirect("/logout");
|
||||||
|
}
|
||||||
35
apps/webapp/app/services/sessionStorage.server.ts
Normal file
35
apps/webapp/app/services/sessionStorage.server.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { createCookieSessionStorage } from "@remix-run/node";
|
||||||
|
import { createThemeSessionResolver } from "remix-themes";
|
||||||
|
import { env } from "~/env.server";
|
||||||
|
|
||||||
|
export const sessionStorage = createCookieSessionStorage({
|
||||||
|
cookie: {
|
||||||
|
name: "__session", // use any name you want here
|
||||||
|
sameSite: "lax", // this helps with CSRF
|
||||||
|
path: "/", // remember to add this so the cookie will work in all routes
|
||||||
|
httpOnly: true, // for security reasons, make this cookie http only
|
||||||
|
secrets: [env.SESSION_SECRET],
|
||||||
|
secure: env.NODE_ENV === "production", // enable this in prod only
|
||||||
|
maxAge: 60 * 60 * 24 * 365, // 7 days
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const themeStorage = createCookieSessionStorage({
|
||||||
|
cookie: {
|
||||||
|
name: "__theme",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secrets: [env.SESSION_SECRET],
|
||||||
|
secure: env.NODE_ENV === "production",
|
||||||
|
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const themeSessionResolver = createThemeSessionResolver(sessionStorage);
|
||||||
|
|
||||||
|
export function getUserSession(request: Request) {
|
||||||
|
return sessionStorage.getSession(request.headers.get("Cookie"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { getSession, commitSession, destroySession } = sessionStorage;
|
||||||
@ -1,85 +1,66 @@
|
|||||||
@import url("non.geist");
|
@import url("non.geist");
|
||||||
@import url("non.geist/mono");
|
@import url("non.geist/mono");
|
||||||
|
|
||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
@apply bg-background;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root {
|
||||||
color-scheme: dark;
|
--background: oklch(91.28% 0 0);
|
||||||
}
|
--background-2: oklch(95.21% 0 0);
|
||||||
|
--background-3: oklch(100% 0 0);
|
||||||
|
--foreground: oklch(0% 0 0);
|
||||||
|
--popover: oklch(93.05% 0 0);
|
||||||
|
--popover-foreground: oklch(0% 0 0);
|
||||||
|
--primary: oklch(54% 0.1789 271);
|
||||||
|
--primary-foreground: oklch(100% 0 0);
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: oklch(0% 0 0);
|
||||||
|
--muted: oklch(0% 0 0 / 19.22%);
|
||||||
|
--muted-foreground: oklch(49.26% 0 0);
|
||||||
|
--accent: oklch(100% 0 0);
|
||||||
|
--accent-foreground: oklch(0% 0 0);
|
||||||
|
--destructive: oklch(61.34% 0.162 23.58);
|
||||||
|
--destructive-foreground: oklch(100% 0 0);
|
||||||
|
--warning: oklch(70.43% 0.14390424619548714 87.9634104985311);
|
||||||
|
--warning-foreground: oklch(100% 0 0);
|
||||||
|
--success: oklch(64.93% 0.107 154.24);
|
||||||
|
--success-foreground: oklch(100% 0 0);
|
||||||
|
--border: oklch(0% 0 0 / 19.22%);
|
||||||
|
--border-dark: oklch(0% 0 0 / 39.22%);
|
||||||
|
--input: oklch(0% 0 0 / 6.27%);
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark,
|
||||||
|
:root[class~="dark"] {
|
||||||
@layer base {
|
--background: oklch(21.34% 0 0);
|
||||||
:root {
|
--background-2: oklch(25.2% 0 0);
|
||||||
--background: 91.28% 0 0;
|
--background-3: oklch(28.5% 0 0);
|
||||||
--background-2: 95.21% 0 0;
|
--foreground: oklch(92.8% 0 0);
|
||||||
--background-3: 100% 0 0;
|
--popover: oklch(28.5% 0 0);
|
||||||
--foreground: 0% 0 0;
|
--popover-foreground: oklch(92.8% 0 0);
|
||||||
--popover: 93.05% 0 0;
|
--primary: oklch(54% 0.1789 271);
|
||||||
--popover-foreground: 0% 0 0;
|
--primary-foreground: oklch(92.8% 0 0);
|
||||||
--primary: 54% 0.1789 271;
|
--secondary: 210 40% 96.1%;
|
||||||
--primary-foreground: 100% 0 0;
|
--secondary-foreground: oklch(92.8% 0 0);
|
||||||
--secondary: 210 40% 96.1%;
|
--muted: oklch(100% 0 0 / 13.33%);
|
||||||
--secondary-foreground: 0% 0 0;
|
--muted-foreground: oklch(76.99% 0 0);
|
||||||
--muted: 0% 0 0 / 19.22%;
|
--accent: oklch(95.81% 0 0);
|
||||||
--muted-foreground: 49.26% 0 0;
|
--accent-foreground: oklch(0% 0 0);
|
||||||
--accent: 100% 0 0;
|
--warning: oklch(70.43% 0.14390424619548714 87.9634104985311);
|
||||||
--accent-foreground: 0% 0 0;
|
--warning-foreground: oklch(100% 0 0);
|
||||||
--destructive: 61.34% 0.162 23.58;
|
--destructive: oklch(61.34% 0.162 23.58);
|
||||||
--destructive-foreground: 100% 0 0;
|
--destructive-foreground: oklch(100% 0 0);
|
||||||
--warning: 70.43% 0.14390424619548714 87.9634104985311;
|
--success: oklch(64.93% 0.107 154.24);
|
||||||
--warning-foreground: 100% 0 0;
|
--success-foreground: oklch(100% 0 0);
|
||||||
--success: 64.93% 0.107 154.24;
|
--border: oklch(100% 0 0 / 13.33%);
|
||||||
--success-foreground: 100% 0 0;
|
--border-dark: oklch(100% 0 0 / 39.33%);
|
||||||
--border: 0% 0 0 / 19.22%;
|
--input: oklch(100% 0 0 / 10.59%);
|
||||||
--border-dark: 0% 0 0 / 39.22%;
|
--ring: 221.2 83.2% 53.3%;
|
||||||
--input: 0% 0 0 / 6.27%;
|
|
||||||
--ring: 221.2 83.2% 53.3%;
|
|
||||||
--radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 21.34% 0 0;
|
|
||||||
--background-2: 25.2% 0 0;
|
|
||||||
--background-3: 28.5% 0 0;
|
|
||||||
--foreground: 92.8% 0 0;
|
|
||||||
--popover: 28.5% 0 0;
|
|
||||||
--popover-foreground: 92.8% 0 0;
|
|
||||||
--primary: 54% 0.1789 271;
|
|
||||||
--primary-foreground: 92.8% 0 0;
|
|
||||||
--secondary: 210 40% 96.1%;
|
|
||||||
--secondary-foreground: 92.8% 0 0;
|
|
||||||
--muted: 100% 0 0 / 13.33%;
|
|
||||||
--muted-foreground: 76.99% 0 0;
|
|
||||||
--accent: 95.81% 0 0;
|
|
||||||
--accent-foreground: 0% 0 0;
|
|
||||||
--warning: 70.43% 0.14390424619548714 87.9634104985311;
|
|
||||||
--warning-foreground: 100% 0 0;
|
|
||||||
--destructive: 61.34% 0.162 23.58;
|
|
||||||
--destructive-foreground: 100% 0 0;
|
|
||||||
--success: 64.93% 0.107 154.24;
|
|
||||||
--success-foreground: 100% 0 0;
|
|
||||||
--border: 100% 0 0 / 13.33%;
|
|
||||||
--border-dark: 100% 0 0 / 39.33%;
|
|
||||||
--input: 100% 0 0 / 10.59%;
|
|
||||||
--ring: 221.2 83.2% 53.3%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
@layer base {
|
--color-foreground: var(--foreground);
|
||||||
* {
|
}
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import type { UIMatch } from "@remix-run/react";
|
import { useMatches, type UIMatch } from "@remix-run/react";
|
||||||
import { useMatches } from "@remix-run/react";
|
|
||||||
|
|
||||||
const DEFAULT_REDIRECT = "/";
|
const DEFAULT_REDIRECT = "/";
|
||||||
|
|
||||||
@ -12,7 +11,7 @@ const DEFAULT_REDIRECT = "/";
|
|||||||
*/
|
*/
|
||||||
export function safeRedirect(
|
export function safeRedirect(
|
||||||
to: FormDataEntryValue | string | null | undefined,
|
to: FormDataEntryValue | string | null | undefined,
|
||||||
defaultRedirect: string = DEFAULT_REDIRECT
|
defaultRedirect: string = DEFAULT_REDIRECT,
|
||||||
) {
|
) {
|
||||||
if (!to || typeof to !== "string") {
|
if (!to || typeof to !== "string") {
|
||||||
return defaultRedirect;
|
return defaultRedirect;
|
||||||
@ -31,7 +30,10 @@ export function safeRedirect(
|
|||||||
* @param {string} id The route id
|
* @param {string} id The route id
|
||||||
* @returns {JSON|undefined} The router data or undefined if not found
|
* @returns {JSON|undefined} The router data or undefined if not found
|
||||||
*/
|
*/
|
||||||
export function useMatchesData(id: string | string[], debug: boolean = false): UIMatch | undefined {
|
export function useMatchesData(
|
||||||
|
id: string | string[],
|
||||||
|
debug: boolean = false,
|
||||||
|
): UIMatch | undefined {
|
||||||
const matchingRoutes = useMatches();
|
const matchingRoutes = useMatches();
|
||||||
|
|
||||||
if (debug) {
|
if (debug) {
|
||||||
@ -41,10 +43,13 @@ export function useMatchesData(id: string | string[], debug: boolean = false): U
|
|||||||
const paths = Array.isArray(id) ? id : [id];
|
const paths = Array.isArray(id) ? id : [id];
|
||||||
|
|
||||||
// Get the first matching route
|
// Get the first matching route
|
||||||
const route = paths.reduce((acc, path) => {
|
const route = paths.reduce(
|
||||||
if (acc) return acc;
|
(acc, path) => {
|
||||||
return matchingRoutes.find((route) => route.id === path);
|
if (acc) return acc;
|
||||||
}, undefined as UIMatch | undefined);
|
return matchingRoutes.find((route) => route.id === path);
|
||||||
|
},
|
||||||
|
undefined as UIMatch | undefined,
|
||||||
|
);
|
||||||
|
|
||||||
return route;
|
return route;
|
||||||
}
|
}
|
||||||
|
|||||||
24
apps/webapp/app/utils/httpErrors.ts
Normal file
24
apps/webapp/app/utils/httpErrors.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export function friendlyErrorDisplay(statusCode: number, statusText?: string) {
|
||||||
|
switch (statusCode) {
|
||||||
|
case 400:
|
||||||
|
return {
|
||||||
|
title: "400: Bad request",
|
||||||
|
message: statusText ?? "The request was invalid.",
|
||||||
|
};
|
||||||
|
case 404:
|
||||||
|
return {
|
||||||
|
title: "404: Page not found",
|
||||||
|
message: statusText ?? "The page you're looking for doesn't exist.",
|
||||||
|
};
|
||||||
|
case 500:
|
||||||
|
return {
|
||||||
|
title: "500: Server error",
|
||||||
|
message: statusText ?? "Something went wrong on our end. Please try again later.",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: `${statusCode}: Error`,
|
||||||
|
message: statusText ?? "An error occurred.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/webapp/app/utils/singleton.ts
Normal file
6
apps/webapp/app/utils/singleton.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export function singleton<T>(name: string, getValue: () => T): T {
|
||||||
|
const thusly = globalThis as any;
|
||||||
|
thusly.__echo_singletons ??= {};
|
||||||
|
thusly.__echo_singletons[name] ??= getValue();
|
||||||
|
return thusly.__echo_singletons[name];
|
||||||
|
}
|
||||||
@ -5,55 +5,85 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "remix vite:build",
|
"build": "remix vite:build",
|
||||||
"dev": "remix vite:dev",
|
"dev": "node ./server.js",
|
||||||
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
||||||
"start": "remix-serve ./build/server/index.js",
|
"start": "remix-serve ./build/server/index.js",
|
||||||
"typecheck": "tsc"
|
"typecheck": "tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/node": "^2.16.7",
|
|
||||||
"@remix-run/react": "^2.16.7",
|
|
||||||
"@remix-run/serve": "^2.16.7",
|
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
|
||||||
"@echo/database": "workspace:*",
|
"@echo/database": "workspace:*",
|
||||||
|
"@opentelemetry/api": "1.9.0",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@remix-run/express": "2.16.7",
|
||||||
|
"@remix-run/node": "2.1.0",
|
||||||
|
"@remix-run/react": "2.16.7",
|
||||||
|
"@remix-run/router": "^1.15.3",
|
||||||
|
"@remix-run/serve": "2.16.7",
|
||||||
|
"@remix-run/server-runtime": "2.16.7",
|
||||||
|
"@remix-run/v1-meta": "^0.1.3",
|
||||||
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
"compression": "^1.7.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"remix-typedjson": "0.3.1",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"express": "^4.18.1",
|
||||||
"isbot": "^4.1.0",
|
"isbot": "^4.1.0",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"nanoid": "3.3.8",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
|
"non.geist": "^1.0.2",
|
||||||
|
"posthog-js": "^1.116.6",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"tailwind-merge": "^1.12.0",
|
"remix-auth": "^3.6.0",
|
||||||
"tailwind-scrollbar-hide": "^1.1.7",
|
"remix-auth-google": "^2.0.0",
|
||||||
"tailwindcss-animate": "^1.0.5",
|
"remix-typedjson": "0.3.1",
|
||||||
|
"remix-utils": "^7.7.0",
|
||||||
|
"remix-themes": "^1.3.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwind-scrollbar-hide": "^2.0.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tailwindcss-textshadow": "^2.1.3",
|
"tailwindcss-textshadow": "^2.1.3",
|
||||||
"non.geist": "^1.0.2",
|
"tiny-invariant": "^1.3.1",
|
||||||
"zod": "3.23.8",
|
"zod": "3.23.8",
|
||||||
"zod-error": "1.5.0",
|
"zod-error": "1.5.0",
|
||||||
"zod-validation-error": "^1.5.0"
|
"zod-validation-error": "^1.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@remix-run/dev": "^2.16.7",
|
"@remix-run/dev": "2.16.7",
|
||||||
|
"@remix-run/eslint-config": "2.16.7",
|
||||||
|
"@remix-run/testing": "^2.16.7",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
|
|
||||||
|
"@types/compression": "^1.7.2",
|
||||||
|
"@types/morgan": "^1.9.3",
|
||||||
|
"@types/express": "^4.17.13",
|
||||||
"@types/react": "^18.2.20",
|
"@types/react": "^18.2.20",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||||
"@typescript-eslint/parser": "^6.7.4",
|
"@typescript-eslint/parser": "^6.7.4",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"esbuild": "0.25.5",
|
"css-loader": "^6.10.0",
|
||||||
"eslint": "^8.38.0",
|
"esbuild": "^0.25.5",
|
||||||
"eslint-import-resolver-typescript": "^3.6.1",
|
"eslint-import-resolver-typescript": "^3.6.1",
|
||||||
"eslint-plugin-import": "^2.28.1",
|
|
||||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint": "^8.24.0",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"eslint-plugin-import": "^2.29.1",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"eslint-plugin-turbo": "^2.0.4",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^2.8.8",
|
"postcss-import": "^16.0.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
"postcss-loader": "^8.1.1",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"prettier": "^3.5.3",
|
||||||
"@tailwindcss/typography": "^0.5.9",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"tailwind-scrollbar": "^3.0.1",
|
"tailwind-scrollbar": "^4.0.2",
|
||||||
"tailwindcss": "3.4.1",
|
"tailwindcss": "4.1.7",
|
||||||
"typescript": "^5.1.6",
|
"typescript": "5.8.3",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vite-tsconfig-paths": "^4.2.1"
|
"vite-tsconfig-paths": "^4.2.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
...require("../../prettier.config.js"),
|
|
||||||
plugins: [require("prettier-plugin-tailwindcss")],
|
|
||||||
};
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
/** @type {import('@remix-run/dev').AppConfig} */
|
|
||||||
module.exports = {
|
|
||||||
dev: {
|
|
||||||
port: 8002,
|
|
||||||
},
|
|
||||||
tailwind: true,
|
|
||||||
postcss: true,
|
|
||||||
cacheDirectory: "./node_modules/.cache/remix",
|
|
||||||
ignoredRouteFiles: ["**/.*"],
|
|
||||||
serverModuleFormat: "cjs",
|
|
||||||
serverDependenciesToBundle: [
|
|
||||||
/^remix-utils.*/,
|
|
||||||
/^@internal\//, // Bundle all internal packages
|
|
||||||
"marked",
|
|
||||||
"axios",
|
|
||||||
"p-limit",
|
|
||||||
"yocto-queue",
|
|
||||||
"@unkey/cache",
|
|
||||||
"@unkey/cache/stores",
|
|
||||||
"emails",
|
|
||||||
"highlight.run",
|
|
||||||
"random-words",
|
|
||||||
"superjson",
|
|
||||||
"prismjs/components/prism-json",
|
|
||||||
"prismjs/components/prism-typescript",
|
|
||||||
"redlock",
|
|
||||||
"parse-duration",
|
|
||||||
],
|
|
||||||
browserNodeBuiltinsPolyfill: { modules: { path: true, os: true, crypto: true } },
|
|
||||||
};
|
|
||||||
51
apps/webapp/server.js
Normal file
51
apps/webapp/server.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { createRequestHandler } from "@remix-run/express";
|
||||||
|
import compression from "compression";
|
||||||
|
import express from "express";
|
||||||
|
import morgan from "morgan";
|
||||||
|
|
||||||
|
const viteDevServer =
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? undefined
|
||||||
|
: await import("vite").then((vite) =>
|
||||||
|
vite.createServer({
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const remixHandler = createRequestHandler({
|
||||||
|
build: viteDevServer
|
||||||
|
? () => viteDevServer.ssrLoadModule("virtual:remix/server-build")
|
||||||
|
: await import("./build/server/index.js"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(compression());
|
||||||
|
|
||||||
|
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
|
||||||
|
// handle asset requests
|
||||||
|
if (viteDevServer) {
|
||||||
|
app.use(viteDevServer.middlewares);
|
||||||
|
} else {
|
||||||
|
// Vite fingerprints its assets so we can cache forever.
|
||||||
|
app.use(
|
||||||
|
"/assets",
|
||||||
|
express.static("build/client/assets", { immutable: true, maxAge: "1y" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else (like favicon.ico) is cached for an hour. You may want to be
|
||||||
|
// more aggressive with this caching.
|
||||||
|
app.use(express.static("build/client", { maxAge: "1h" }));
|
||||||
|
|
||||||
|
app.use(morgan("tiny"));
|
||||||
|
|
||||||
|
// handle SSR requests
|
||||||
|
app.all("*", remixHandler);
|
||||||
|
|
||||||
|
const port = process.env.REMIX_APP_PORT || 3000;
|
||||||
|
app.listen(port, () =>
|
||||||
|
console.log(`Express server listening at http://localhost:${port}`),
|
||||||
|
);
|
||||||
@ -1,167 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
const colors = require("tailwindcss/colors");
|
|
||||||
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: ["class"],
|
|
||||||
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
|
|
||||||
theme: {
|
|
||||||
container: {
|
|
||||||
center: true,
|
|
||||||
padding: "2rem",
|
|
||||||
screens: {
|
|
||||||
"2xl": "1400px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: ["Geist Variable", "Helvetica Neue", "Helvetica", "Arial", "sans-serif"],
|
|
||||||
mono: ["Geist Mono Variable", "monaco", "Consolas", "Lucida Console", "monospace"],
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: 'var(--radius)',
|
|
||||||
md: 'calc(var(--radius) - 2px)',
|
|
||||||
sm: 'calc(var(--radius) - 4px)'
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
border: {
|
|
||||||
DEFAULT: 'oklch(var(--border))',
|
|
||||||
dark: 'oklch(var(--border-dark))',
|
|
||||||
},
|
|
||||||
input: 'oklch(var(--input))',
|
|
||||||
ring: 'oklch(var(--ring))',
|
|
||||||
background: {
|
|
||||||
'2': 'oklch(var(--background-2) / <alpha-value>)',
|
|
||||||
'3': 'oklch(var(--background-3) / <alpha-value>)',
|
|
||||||
DEFAULT: 'oklch(var(--background) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
foreground: 'oklch(var(--foreground) / <alpha-value>)',
|
|
||||||
primary: {
|
|
||||||
DEFAULT: 'oklch(var(--primary) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--primary-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
|
||||||
foreground: 'hsl(var(--secondary-foreground))',
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: 'oklch(var(--destructive) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--destructive-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
DEFAULT: 'oklch(var(--warning) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--warning-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
DEFAULT: 'oklch(var(--success) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--success-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: 'oklch(var(--muted))',
|
|
||||||
foreground: 'oklch(var(--muted-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: 'oklch(var(--accent) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--accent-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: 'oklch(var(--popover) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--popover-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
gray: {
|
|
||||||
'50': 'var(--gray-50)',
|
|
||||||
'100': 'var(--gray-100)',
|
|
||||||
'200': 'var(--gray-200)',
|
|
||||||
'300': 'var(--gray-300)',
|
|
||||||
'400': 'var(--gray-400)',
|
|
||||||
'500': 'var(--gray-500)',
|
|
||||||
'600': 'var(--gray-600)',
|
|
||||||
'700': 'var(--gray-700)',
|
|
||||||
'800': 'var(--gray-800)',
|
|
||||||
'900': 'var(--gray-900)',
|
|
||||||
'950': 'var(--gray-950)',
|
|
||||||
},
|
|
||||||
grayAlpha: {
|
|
||||||
'50': 'oklch(var(--grayAlpha-50))',
|
|
||||||
'100': 'oklch(var(--grayAlpha-100))',
|
|
||||||
'200': 'oklch(var(--grayAlpha-200))',
|
|
||||||
'300': 'oklch(var(--grayAlpha-300))',
|
|
||||||
'400': 'oklch(var(--grayAlpha-400))',
|
|
||||||
'500': 'oklch(var(--grayAlpha-500))',
|
|
||||||
'600': 'oklch(var(--grayAlpha-600))',
|
|
||||||
'700': 'oklch(var(--grayAlpha-700))',
|
|
||||||
'800': 'oklch(var(--grayAlpha-800))',
|
|
||||||
'900': 'oklch(var(--grayAlpha-900))',
|
|
||||||
'950': 'oklch(var(--grayAlpha-950))',
|
|
||||||
},
|
|
||||||
red: {
|
|
||||||
'50': '#fdf3f3',
|
|
||||||
'100': '#fbe9e8',
|
|
||||||
'200': '#f7d4d4',
|
|
||||||
'300': '#f0b1b1',
|
|
||||||
'400': '#e78587',
|
|
||||||
'500': '#d75056',
|
|
||||||
'600': '#c43a46',
|
|
||||||
'700': '#a52b3a',
|
|
||||||
'800': '#8a2735',
|
|
||||||
'900': '#772433',
|
|
||||||
'950': '#420f18',
|
|
||||||
},
|
|
||||||
orange: {
|
|
||||||
'50': '#fdf6ef',
|
|
||||||
'100': '#fbead9',
|
|
||||||
'200': '#f7d2b1',
|
|
||||||
'300': '#f1b480',
|
|
||||||
'400': '#ea8c4d',
|
|
||||||
'500': '#e67333',
|
|
||||||
'600': '#d65520',
|
|
||||||
'700': '#b2401c',
|
|
||||||
'800': '#8e341e',
|
|
||||||
'900': '#732d1b',
|
|
||||||
'950': '#3e140c',
|
|
||||||
},
|
|
||||||
yellow: {
|
|
||||||
'50': '#fdfbe9',
|
|
||||||
'100': '#faf7c7',
|
|
||||||
'200': '#f7ec91',
|
|
||||||
'300': '#f1db53',
|
|
||||||
'400': '#ebc724',
|
|
||||||
'500': '#dcb016',
|
|
||||||
'600': '#c28c11',
|
|
||||||
'700': '#976211',
|
|
||||||
'800': '#7d4f16',
|
|
||||||
'900': '#6b4118',
|
|
||||||
'950': '#3e220a',
|
|
||||||
},
|
|
||||||
sidebar: {
|
|
||||||
DEFAULT: 'oklch(var(--background-2) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--foreground) / <alpha-value>)',
|
|
||||||
primary: 'oklch(var(--primary) / <alpha-value>)',
|
|
||||||
'primary-foreground':
|
|
||||||
'oklch(var(--primary-foreground) / <alpha-value>)',
|
|
||||||
accent: 'oklch(var(--accent) / <alpha-value>)',
|
|
||||||
'accent-foreground':
|
|
||||||
'oklch(var(--accent-foreground) / <alpha-value>)',
|
|
||||||
border: 'oklch(var(--border))',
|
|
||||||
ring: 'oklch(var(--ring))',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: [ require("@tailwindcss/container-queries"),
|
|
||||||
require("@tailwindcss/forms"),
|
|
||||||
require("@tailwindcss/typography"),
|
|
||||||
require("tailwindcss-animate"),
|
|
||||||
require("tailwind-scrollbar"),
|
|
||||||
require("tailwind-scrollbar-hide"),
|
|
||||||
require("tailwindcss-textshadow"),
|
|
||||||
function ({ addUtilities, theme }) {
|
|
||||||
const focusStyles = theme("focusStyles", {});
|
|
||||||
addUtilities({
|
|
||||||
".focus-custom": {
|
|
||||||
"&:focus-visible": focusStyles,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},],
|
|
||||||
}
|
|
||||||
169
apps/webapp/tailwind.config.ts
Normal file
169
apps/webapp/tailwind.config.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
"Geist Variable",
|
||||||
|
"Helvetica Neue",
|
||||||
|
"Helvetica",
|
||||||
|
"Arial",
|
||||||
|
"sans-serif",
|
||||||
|
],
|
||||||
|
mono: [
|
||||||
|
"Geist Mono Variable",
|
||||||
|
"monaco",
|
||||||
|
"Consolas",
|
||||||
|
"Lucida Console",
|
||||||
|
"monospace",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
border: {
|
||||||
|
DEFAULT: "oklch(var(--border))",
|
||||||
|
dark: "oklch(var(--border-dark))",
|
||||||
|
},
|
||||||
|
input: "oklch(var(--input))",
|
||||||
|
ring: "oklch(var(--ring))",
|
||||||
|
background: {
|
||||||
|
2: "oklch(var(--background-2) / <alpha-value>)",
|
||||||
|
3: "oklch(var(--background-3) / <alpha-value>)",
|
||||||
|
DEFAULT: "oklch(var(--background) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
foreground: "oklch(var(--foreground) / <alpha-value>)",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "oklch(var(--primary) / <alpha-value>)",
|
||||||
|
foreground: "oklch(var(--primary-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "oklch(var(--destructive) / <alpha-value>)",
|
||||||
|
foreground: "oklch(var(--destructive-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: "oklch(var(--warning) / <alpha-value>)",
|
||||||
|
foreground: "oklch(var(--warning-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: "oklch(var(--success) / <alpha-value>)",
|
||||||
|
foreground: "oklch(var(--success-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "oklch(var(--muted))",
|
||||||
|
foreground: "oklch(var(--muted-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "oklch(var(--accent) / <alpha-value>)",
|
||||||
|
foreground: "oklch(var(--accent-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "oklch(var(--popover) / <alpha-value>)",
|
||||||
|
foreground: "oklch(var(--popover-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
50: "var(--gray-50)",
|
||||||
|
100: "var(--gray-100)",
|
||||||
|
200: "var(--gray-200)",
|
||||||
|
300: "var(--gray-300)",
|
||||||
|
400: "var(--gray-400)",
|
||||||
|
500: "var(--gray-500)",
|
||||||
|
600: "var(--gray-600)",
|
||||||
|
700: "var(--gray-700)",
|
||||||
|
800: "var(--gray-800)",
|
||||||
|
900: "var(--gray-900)",
|
||||||
|
950: "var(--gray-950)",
|
||||||
|
},
|
||||||
|
grayAlpha: {
|
||||||
|
50: "oklch(var(--grayAlpha-50))",
|
||||||
|
100: "oklch(var(--grayAlpha-100))",
|
||||||
|
200: "oklch(var(--grayAlpha-200))",
|
||||||
|
300: "oklch(var(--grayAlpha-300))",
|
||||||
|
400: "oklch(var(--grayAlpha-400))",
|
||||||
|
500: "oklch(var(--grayAlpha-500))",
|
||||||
|
600: "oklch(var(--grayAlpha-600))",
|
||||||
|
700: "oklch(var(--grayAlpha-700))",
|
||||||
|
800: "oklch(var(--grayAlpha-800))",
|
||||||
|
900: "oklch(var(--grayAlpha-900))",
|
||||||
|
950: "oklch(var(--grayAlpha-950))",
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
50: "#fdf3f3",
|
||||||
|
100: "#fbe9e8",
|
||||||
|
200: "#f7d4d4",
|
||||||
|
300: "#f0b1b1",
|
||||||
|
400: "#e78587",
|
||||||
|
500: "#d75056",
|
||||||
|
600: "#c43a46",
|
||||||
|
700: "#a52b3a",
|
||||||
|
800: "#8a2735",
|
||||||
|
900: "#772433",
|
||||||
|
950: "#420f18",
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
50: "#fdf6ef",
|
||||||
|
100: "#fbead9",
|
||||||
|
200: "#f7d2b1",
|
||||||
|
300: "#f1b480",
|
||||||
|
400: "#ea8c4d",
|
||||||
|
500: "#e67333",
|
||||||
|
600: "#d65520",
|
||||||
|
700: "#b2401c",
|
||||||
|
800: "#8e341e",
|
||||||
|
900: "#732d1b",
|
||||||
|
950: "#3e140c",
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
50: "#fdfbe9",
|
||||||
|
100: "#faf7c7",
|
||||||
|
200: "#f7ec91",
|
||||||
|
300: "#f1db53",
|
||||||
|
400: "#ebc724",
|
||||||
|
500: "#dcb016",
|
||||||
|
600: "#c28c11",
|
||||||
|
700: "#976211",
|
||||||
|
800: "#7d4f16",
|
||||||
|
900: "#6b4118",
|
||||||
|
950: "#3e220a",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: "oklch(var(--background-2) / <alpha-value>)",
|
||||||
|
foreground: "oklch(var(--foreground) / <alpha-value>)",
|
||||||
|
primary: "oklch(var(--primary) / <alpha-value>)",
|
||||||
|
"primary-foreground":
|
||||||
|
"oklch(var(--primary-foreground) / <alpha-value>)",
|
||||||
|
accent: "oklch(var(--accent) / <alpha-value>)",
|
||||||
|
"accent-foreground":
|
||||||
|
"oklch(var(--accent-foreground) / <alpha-value>)",
|
||||||
|
border: "oklch(var(--border))",
|
||||||
|
ring: "oklch(var(--ring))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require("@tailwindcss/container-queries"),
|
||||||
|
require("@tailwindcss/forms"),
|
||||||
|
require("@tailwindcss/typography"),
|
||||||
|
require("tailwindcss-animate"),
|
||||||
|
require("tailwind-scrollbar"),
|
||||||
|
require("tailwind-scrollbar-hide"),
|
||||||
|
require("tailwindcss-textshadow"),
|
||||||
|
],
|
||||||
|
} satisfies Config;
|
||||||
@ -1,8 +1,15 @@
|
|||||||
{
|
{
|
||||||
"exclude": [],
|
"exclude": [],
|
||||||
"include": ["remix.env.d.ts", "global.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": [
|
||||||
|
"remix.env.d.ts",
|
||||||
|
"global.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"tailwind.config.js",
|
||||||
|
"tailwind.config.js"
|
||||||
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": [],
|
"types": ["@remix-run/node", "vite/client"],
|
||||||
"lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2020"],
|
"lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2020"],
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { vitePlugin as remix } from "@remix-run/dev";
|
import { vitePlugin as remix } from "@remix-run/dev";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
declare module "@remix-run/node" {
|
declare module "@remix-run/node" {
|
||||||
interface Future {
|
interface Future {
|
||||||
@ -10,6 +11,7 @@ declare module "@remix-run/node" {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
remix({
|
remix({
|
||||||
future: {
|
future: {
|
||||||
v3_fetcherPersist: true,
|
v3_fetcherPersist: true,
|
||||||
|
|||||||
26
package.json
26
package.json
@ -2,24 +2,26 @@
|
|||||||
"name": "echo",
|
"name": "echo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces":
|
"workspaces":
|
||||||
[ "apps/*" ]
|
[ "apps/*", "packages/*" ]
|
||||||
,
|
,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "dotenv -- turbo run build",
|
||||||
"dev": "turbo run dev",
|
"dev": "dotenv -- turbo run dev",
|
||||||
"lint": "turbo run lint",
|
"lint": "dotenv -- turbo run lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
"format": "dotenv -- prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
"check-types": "turbo run check-types",
|
"check-types": "dotenv -- turbo run check-types",
|
||||||
"db:migrate": "turbo run db:migrate:deploy generate",
|
"db:migrate": "dotenv -- turbo run db:migrate:deploy generate",
|
||||||
"db:seed": "turbo run db:seed",
|
"db:migrate:create": "dotenv -- turbo run db:migrate:create generate",
|
||||||
"db:studio": "turbo run db:studio",
|
"db:seed": "dotenv -- turbo run db:seed",
|
||||||
"db:populate": "turbo run db:populate",
|
"db:studio": "dotenv -- turbo run db:studio",
|
||||||
"generate": "turbo run generate"
|
"db:populate": "dotenv -- turbo run db:populate",
|
||||||
|
"generate": "dotenv -- turbo run generate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"dotenv-cli": "^7.4.4",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"turbo": "^2.5.3",
|
"turbo": "^2.5.3",
|
||||||
"typescript": "5.8.2"
|
"typescript": "5.5.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@changesets/cli": "2.26.2",
|
"@changesets/cli": "2.26.2",
|
||||||
|
|||||||
@ -9,12 +9,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prisma": "5.4.1",
|
"prisma": "5.4.1",
|
||||||
"rimraf": "6.0.1"
|
"rimraf": "6.0.1",
|
||||||
|
"esbuild": "^0.15.10"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"generate": "prisma generate",
|
"generate": "prisma generate",
|
||||||
"db:migrate:dev:create": "prisma migrate dev --create-only",
|
"db:migrate:create": "prisma migrate dev --preview-feature",
|
||||||
"db:migrate:deploy": "prisma migrate deploy",
|
"db:migrate:deploy": "prisma migrate deploy",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
|
|||||||
@ -0,0 +1,84 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AuthenticationMethod" AS ENUM ('GOOGLE');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"authenticationMethod" "AuthenticationMethod" NOT NULL,
|
||||||
|
"authenticationProfile" JSONB,
|
||||||
|
"authenticationExtraParams" JSONB,
|
||||||
|
"authIdentifier" TEXT,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"name" TEXT,
|
||||||
|
"avatarUrl" TEXT,
|
||||||
|
"admin" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"marketingEmails" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"confirmedBasicDetails" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"referralSource" TEXT,
|
||||||
|
"invitationCodeId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AuthorizationCode" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"personalAccessTokenId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AuthorizationCode_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PersonalAccessToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"encryptedToken" JSONB NOT NULL,
|
||||||
|
"obfuscatedToken" TEXT NOT NULL,
|
||||||
|
"hashedToken" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
"lastAccessedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "InvitationCode" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "InvitationCode_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_authIdentifier_key" ON "User"("authIdentifier");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AuthorizationCode_code_key" ON "AuthorizationCode"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PersonalAccessToken_hashedToken_key" ON "PersonalAccessToken"("hashedToken");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "InvitationCode_code_key" ON "InvitationCode"("code");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_invitationCodeId_fkey" FOREIGN KEY ("invitationCodeId") REFERENCES "InvitationCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AuthorizationCode" ADD CONSTRAINT "AuthorizationCode_personalAccessTokenId_fkey" FOREIGN KEY ("personalAccessTokenId") REFERENCES "PersonalAccessToken"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "IngestionStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "memoryFilter" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Space" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"autoMode" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Space_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Entity" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"metadata" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Entity_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SpaceEntity" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"spaceId" TEXT NOT NULL,
|
||||||
|
"entityId" TEXT NOT NULL,
|
||||||
|
"settings" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SpaceEntity_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IngestionQueue" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"spaceId" TEXT NOT NULL,
|
||||||
|
"data" JSONB NOT NULL,
|
||||||
|
"status" "IngestionStatus" NOT NULL,
|
||||||
|
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"error" TEXT,
|
||||||
|
"retryCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"processedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "IngestionQueue_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Entity_name_key" ON "Entity"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SpaceEntity_spaceId_entityId_key" ON "SpaceEntity"("spaceId", "entityId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Space" ADD CONSTRAINT "Space_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SpaceEntity" ADD CONSTRAINT "SpaceEntity_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SpaceEntity" ADD CONSTRAINT "SpaceEntity_entityId_fkey" FOREIGN KEY ("entityId") REFERENCES "Entity"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IngestionQueue" ADD CONSTRAINT "IngestionQueue_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
@ -23,27 +23,22 @@ model User {
|
|||||||
name String?
|
name String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
|
||||||
admin Boolean @default(false)
|
memoryFilter String? // Adding memory filter instructions
|
||||||
|
|
||||||
/// Preferences for the dashboard
|
admin Boolean @default(false)
|
||||||
dashboardPreferences Json?
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
/// @deprecated
|
|
||||||
isOnCloudWaitlist Boolean @default(false)
|
|
||||||
/// @deprecated
|
|
||||||
featureCloud Boolean @default(false)
|
|
||||||
/// @deprecated
|
|
||||||
isOnHostedRepoWaitlist Boolean @default(false)
|
|
||||||
|
|
||||||
marketingEmails Boolean @default(true)
|
marketingEmails Boolean @default(true)
|
||||||
confirmedBasicDetails Boolean @default(false)
|
confirmedBasicDetails Boolean @default(false)
|
||||||
|
|
||||||
referralSource String?
|
referralSource String?
|
||||||
|
|
||||||
personalAccessTokens PersonalAccessToken[]
|
personalAccessTokens PersonalAccessToken[]
|
||||||
|
InvitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id])
|
||||||
|
invitationCodeId String?
|
||||||
|
Space Space[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuthenticationMethod {
|
enum AuthenticationMethod {
|
||||||
@ -90,3 +85,95 @@ model PersonalAccessToken {
|
|||||||
|
|
||||||
authorizationCodes AuthorizationCode[]
|
authorizationCodes AuthorizationCode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InvitationCode {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
|
||||||
|
users User[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Space model for user workspaces
|
||||||
|
model Space {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
autoMode Boolean @default(false)
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
userId String
|
||||||
|
|
||||||
|
// Space's enabled entities
|
||||||
|
enabledEntities SpaceEntity[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
IngestionQueue IngestionQueue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity types that can be stored in the memory plane
|
||||||
|
model Entity {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique // e.g., "User", "Issue", "Task", "Automation"
|
||||||
|
metadata Json // Store field definitions and their types
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
spaceEntities SpaceEntity[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Junction table for Space-Entity relationship (what entities are enabled in each space)
|
||||||
|
model SpaceEntity {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
space Space @relation(fields: [spaceId], references: [id])
|
||||||
|
spaceId String
|
||||||
|
|
||||||
|
entity Entity @relation(fields: [entityId], references: [id])
|
||||||
|
entityId String
|
||||||
|
|
||||||
|
// Custom settings for this entity in this space
|
||||||
|
settings Json?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([spaceId, entityId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue for processing ingestion tasks
|
||||||
|
model IngestionQueue {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
space Space @relation(fields: [spaceId], references: [id])
|
||||||
|
spaceId String
|
||||||
|
|
||||||
|
// Queue metadata
|
||||||
|
data Json // The actual data to be processed
|
||||||
|
status IngestionStatus
|
||||||
|
priority Int @default(0)
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
error String?
|
||||||
|
retryCount Int @default(0)
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
processedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IngestionStatus {
|
||||||
|
PENDING
|
||||||
|
PROCESSING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|||||||
2716
pnpm-lock.yaml
generated
2716
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
35
turbo.json
35
turbo.json
@ -5,7 +5,13 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"dependsOn": [ "^build" ],
|
"dependsOn": [ "^build" ],
|
||||||
"inputs": [ "$TURBO_DEFAULT$", ".env*" ],
|
"inputs": [ "$TURBO_DEFAULT$", ".env*" ],
|
||||||
"outputs": [ ".next/**", "!.next/cache/**" ]
|
"outputs": [
|
||||||
|
"dist/**",
|
||||||
|
"public/build/**",
|
||||||
|
"build/**",
|
||||||
|
"app/styles/tailwind.css",
|
||||||
|
".cache"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"dependsOn": [ "^lint" ]
|
"dependsOn": [ "^lint" ]
|
||||||
@ -14,8 +20,7 @@
|
|||||||
"dependsOn": [ "^check-types" ]
|
"dependsOn": [ "^check-types" ]
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false
|
||||||
"persistent": true
|
|
||||||
},
|
},
|
||||||
"db:generate": {
|
"db:generate": {
|
||||||
"cache": false
|
"cache": false
|
||||||
@ -23,6 +28,10 @@
|
|||||||
"db:migrate:deploy": {
|
"db:migrate:deploy": {
|
||||||
"cache": false
|
"cache": false
|
||||||
},
|
},
|
||||||
|
"db:migrate:create": {
|
||||||
|
"cache": false,
|
||||||
|
"interactive": true
|
||||||
|
},
|
||||||
"db:studio": {
|
"db:studio": {
|
||||||
"cache": false
|
"cache": false
|
||||||
},
|
},
|
||||||
@ -31,5 +40,23 @@
|
|||||||
"^generate"
|
"^generate"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"globalDependencies": [
|
||||||
|
".env"
|
||||||
|
],
|
||||||
|
"globalEnv": [
|
||||||
|
"NODE_ENV",
|
||||||
|
"REMIX_APP_PORT",
|
||||||
|
"CI",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"DIRECT_URL",
|
||||||
|
"SESSION_SECRET",
|
||||||
|
"APP_ORIGIN",
|
||||||
|
"LOGIN_ORIGIN",
|
||||||
|
"POSTHOG_PROJECT_KEY",
|
||||||
|
"AUTH_GOOGLE_CLIENT_ID",
|
||||||
|
"AUTH_GOOGLE_CLIENT_SECRET",
|
||||||
|
"APP_ENV",
|
||||||
|
"APP_LOG_LEVEL"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user