diff --git a/.github/workflows/test-build-deploy.yml b/.github/workflows/test-build-deploy.yml index 51914182..2d286433 100644 --- a/.github/workflows/test-build-deploy.yml +++ b/.github/workflows/test-build-deploy.yml @@ -16,7 +16,7 @@ jobs: # env: # DATABASE_URL: ${{ secrets.DATABASE_URL }} # JWT_SECRET: ${{ secrets.JWT_SECRET }} - # NEXT_PUBLIC_APP_URL: http://localhost:8080 + # APP_URL: http://localhost:8080 # API_URL: http://localhost:3333 # LUNARY_PUBLIC_KEY: 259d2d94-9446-478a-ae04-484de705b522 # OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/.gitignore b/.gitignore index c12aaad6..946050d5 100644 --- a/.gitignore +++ b/.gitignore @@ -200,8 +200,8 @@ entrypoint.sh # Python -.venv +venv __pycache__/ *.pyc - +venv test_*.py \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e065131c..25ad9af0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,23 @@ 4. Copy the content of `packages/backend/.env.example` to `packages/backend/.env` and fill the missing values 5. Copy the content of `packages/frontend/.env.example` to `packages/backend/.env` 6. Run `npm install` -7. Run `npm run dev` +7. Run `npm run migrate:db` +8. Run `npm run dev` -You can now open the dashboard at `http://localhost:8080`. When using our JS or Python SDK, you need to set the ennvironment variable `LUNARY_API_URL` to `http://localhost:3333`. +You can now open the dashboard at `http://localhost:8080`. When using our JS or Python SDK, you need to set the environment variable `LUNARY_API_URL` to `http://localhost:3333`. + +## Contributing Guidelines + +We welcome contributions to this project! + +When contributing, please follow these guidelines: + +- Before starting work on a new feature or bug fix, open an issue to discuss the proposed changes. This allows for coordination and avoids duplication of effort. +- Fork the repository and create a new branch for your changes. Use a descriptive branch name that reflects the purpose of your changes. +- Write clear, concise commit messages that describe the purpose of each commit. +- Make sure to update any relevant documentation, including README files and code comments. +- Make sure all tests pass before submitting a pull request. +- When submitting a pull request, provide a detailed description of your changes and reference any related issues. +- Be responsive to feedback and be willing to make changes to your pull request if requested. + +Thank you for your contributions! diff --git a/ops b/ops index c03dfdb9..8c08efc6 160000 --- a/ops +++ b/ops @@ -1 +1 @@ -Subproject commit c03dfdb96464eb6d4894448dc308d84c48649472 +Subproject commit 8c08efc66fb13988c9d96da7e7066a8d6d564071 diff --git a/package-lock.json b/package-lock.json index 71339baa..844302e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4193,19 +4193,6 @@ } } }, - "node_modules/next-plausible": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/next-plausible/-/next-plausible-3.12.0.tgz", - "integrity": "sha512-SSkEqKQ6PgR8fx3sYfIAT69k2xuCUXO5ngkSS19CjxY97lAoZxsfZpYednxB4zo0mHYv87JzhPynrdBPlCBVHg==", - "funding": { - "url": "https://github.com/4lejandrito/next-plausible?sponsor=1" - }, - "peerDependencies": { - "next": "^11.1.0 || ^12.0.0 || ^13.0.0 || ^14.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/next-seo": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/next-seo/-/next-seo-6.5.0.tgz", @@ -6823,13 +6810,11 @@ "@tanstack/react-virtual": "3.0.0-alpha.0", "bcrypt": "^5.1.1", "crisp-sdk-web": "^1.0.21", - "date-fns": "^3.3.1", + "date-fns": "^3.6.0", "jose": "^5.2.0", "jsonrepair": "^3.5.1", "next": "^14.1.0", - "next-plausible": "^3.12.0", "next-seo": "^6.4.0", - "postgres": "^3.4.3", "posthog-js": "^1.103.1", "random-word-slugs": "^0.1.7", "react": "18.2.0", @@ -6859,8 +6844,9 @@ } }, "packages/frontend/node_modules/date-fns": { - "version": "3.3.1", - "license": "MIT", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 96c4c790..a18af13f 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -1,10 +1,9 @@ DATABASE_URL="postgresql://postgres:password@your-host:5432/postgres" JWT_SECRET=yoursupersecret -NEXT_PUBLIC_APP_URL=http://localhost:8080 -SKIP_EMAIL_VERIFY=true +APP_URL=http://localhost:8080 -# optionnal (for the playground, evaluation and radar features) +# optional (for the playground, evaluation and radar features) LUNARY_PUBLIC_KEY=259d2d94-9446-478a-ae04-484de705b522 OPENAI_API_KEY=sk-... OPENROUTER_API_KEY=sk-... diff --git a/packages/backend/src/api/v1/auth/index.ts b/packages/backend/src/api/v1/auth/index.ts index 385e8fa9..d007eda2 100644 --- a/packages/backend/src/api/v1/auth/index.ts +++ b/packages/backend/src/api/v1/auth/index.ts @@ -46,13 +46,14 @@ auth.post("/method", async (ctx: Context) => { auth.post("/signup", async (ctx: Context) => { const bodySchema = z.object({ email: z.string().email().transform(sanitizeEmail), - password: z.string().min(6), + password: z.string().min(6).optional(), // optional if SAML flow name: z.string(), orgName: z.string().optional(), projectName: z.string().optional(), employeeCount: z.string().optional(), orgId: z.string().optional(), token: z.string().optional(), + redirectUrl: z.string().optional(), signupMethod: z.enum(["signup", "join"]), }) @@ -65,30 +66,41 @@ auth.post("/signup", async (ctx: Context) => { employeeCount, orgId, signupMethod, + redirectUrl, token, } = bodySchema.parse(ctx.request.body) + // Spamming hotfix if (orgName?.includes("https://") || name.includes("http://")) { ctx.throw(403, "Bad request") } - const [existingUser] = await sql` - select * from account where lower(email) = lower(${email}) - ` if (signupMethod === "signup") { const { user, org } = await sql.begin(async (sql) => { const plan = process.env.DEFAULT_PLAN || "free" + const [existingUser] = await sql` + select * from account where lower(email) = lower(${email}) + ` + + if (!password) { + ctx.throw(403, "Password is required") + } + + if (existingUser) { + ctx.throw(403, "User already exists") + } + const [org] = await sql`insert into org ${sql({ name: orgName || `${name}'s Org`, plan })} returning *` const newUser = { name, - passwordHash: await hashPassword(password), + passwordHash: await hashPassword(password!), email, orgId: org.id, role: "owner", - verified: process.env.SKIP_EMAIL_VERIFY ? true : false, + verified: !process.env.RESEND_KEY ? true : false, lastLoginAt: new Date(), } @@ -114,7 +126,7 @@ auth.post("/signup", async (ctx: Context) => { projectId: project.id, apiKey: project.id, } - sql` + await sql` insert into api_key ${sql(publicKey)} ` const privateKey = [ @@ -151,25 +163,24 @@ auth.post("/signup", async (ctx: Context) => { return } else if (signupMethod === "join") { const { payload } = await verifyJwt(token!) + if (payload.email !== email) { - ctx.throw(403, "Wrong email") + ctx.throw(403, "Invalid token") } - const newUser = { + const update = { name, - passwordHash: await hashPassword(password), - email, - orgId, - role: "member", verified: true, + singleUseToken: null, + } + + if (password) { + update.passwordHash = await hashPassword(password) } - const [user] = await sql` - update account set - name = ${newUser.name}, - password_hash = ${newUser.passwordHash}, - verified = true, - single_use_token = null - where email = ${newUser.email} + + await sql` + update account set ${sql(update)} + where email = ${email} and org_id = ${orgId!} returning * ` @@ -189,8 +200,6 @@ auth.get("/join-data", async (ctx: Context) => { select name, plan from org where id = ${orgId} ` - console.log(org) - const [orgUserCountResult] = await sql` select count(*) from account where org_id = ${orgId} ` @@ -272,7 +281,7 @@ auth.post("/request-password-reset", async (ctx: Context) => { await sql`update account set recovery_token = ${token} where id = ${user.id}` - const link = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${token}` + const link = `${process.env.APP_URL}/reset-password?token=${token}` await sendEmail(RESET_PASSWORD(email, link)) diff --git a/packages/backend/src/api/v1/auth/saml.ts b/packages/backend/src/api/v1/auth/saml.ts index f865d1cc..88a06ce0 100644 --- a/packages/backend/src/api/v1/auth/saml.ts +++ b/packages/backend/src/api/v1/auth/saml.ts @@ -15,8 +15,6 @@ const route = new Router({ prefix: "/saml/:orgId", }) -const BASE_URL = process.env.SAML_BASE_URL || process.env.API_URL - // This function generates a secure, one-time-use token export async function generateOneTimeToken(): Promise { // Generate a 32-byte random buffer @@ -37,9 +35,9 @@ function getSpMetadata(orgId: string) { return ` - + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified - + Lunary LLC @@ -94,7 +92,7 @@ function parseAttributes(attributes: any) { route.get("/success", async (ctx: Context) => { const { orgId } = ctx.params as { orgId: string } - ctx.redirect(process.env.NEXT_PUBLIC_APP_URL!) + ctx.redirect(process.env.APP_URL!) }) // Returns the Service Provider metadata @@ -116,6 +114,7 @@ route.post("/download-idp-xml", async (ctx: Context) => { await sql`update org set saml_idp_xml = ${xml} where id = ${orgId}` + ctx.body = { success: true } ctx.status = 201 }) @@ -146,10 +145,10 @@ route.post("/acs", async (ctx: Context) => { const { email, name } = parseAttributes(attributes) - const onetimeToken = await generateOneTimeToken() + const singleUseToken = await generateOneTimeToken() const [account] = - await sql`update account set ${sql({ name, onetimeToken, lastLoginAt: new Date() })} where email = ${email} and org_id = ${orgId} returning *` + await sql`update account set ${sql({ name, singleUseToken, lastLoginAt: new Date() })} where email = ${email} and org_id = ${orgId} returning *` if (!account) { ctx.throw( @@ -159,7 +158,7 @@ route.post("/acs", async (ctx: Context) => { } // Redirect with an one-time token that can be exchanged for an auth token - ctx.redirect(`${process.env.NEXT_PUBLIC_APP_URL!}/login?ott=${onetimeToken}`) + ctx.redirect(`${process.env.APP_URL!}/login?ott=${singleUseToken}`) }) route.post("/slo", async (ctx: Context) => { diff --git a/packages/backend/src/api/v1/orgs.ts b/packages/backend/src/api/v1/orgs.ts index 85d656d6..026b2c44 100644 --- a/packages/backend/src/api/v1/orgs.ts +++ b/packages/backend/src/api/v1/orgs.ts @@ -94,7 +94,7 @@ orgs.get("/billing-portal", async (ctx: Context) => { const session = await stripe.billingPortal.sessions.create({ customer: org.stripeCustomer, - return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`, + return_url: `${process.env.APP_URL}/billing`, }) ctx.body = { url: session.url } diff --git a/packages/backend/src/api/v1/users.ts b/packages/backend/src/api/v1/users.ts index 2176f6fa..9770972d 100644 --- a/packages/backend/src/api/v1/users.ts +++ b/packages/backend/src/api/v1/users.ts @@ -113,7 +113,7 @@ users.get("/verify-email", async (ctx: Context) => { await sendEmail(WELCOME_EMAIL(email, name, id)) // redirect to home page - ctx.redirect(process.env.NEXT_PUBLIC_APP_URL!) + ctx.redirect(process.env.APP_URL!) }) users.post("/send-verification", async (ctx: Context) => { @@ -205,7 +205,7 @@ users.post("/", checkAccess("teamMembers", "create"), async (ctx: Context) => { ` if (!org.samlEnabled) { - const link = `${process.env.NEXT_PUBLIC_APP_URL}/join?token=${token}` + const link = `${process.env.APP_URL}/join?token=${token}` await sendEmail(INVITE_EMAIL(email, org.name, link)) } diff --git a/packages/backend/src/checks/ai/ner.ts b/packages/backend/src/checks/ai/ner.ts deleted file mode 100644 index 637d5ceb..00000000 --- a/packages/backend/src/checks/ai/ner.ts +++ /dev/null @@ -1,67 +0,0 @@ -// import { pipeline } from "@xenova/transformers" - -// let nerPipeline: any = null -// let loading = false - -// type Output = { -// entity: string -// score: number -// index: number -// word: string -// start: null -// end: null -// }[] - -// type Entities = { -// per: string[] -// loc: string[] -// org: string[] -// } - -// export default async function aiNER(sentence?: string): Promise { -// const entities: Entities = { per: [], loc: [], org: [] } - -// if (!sentence) return { per: [], loc: [], org: [] } - -// if (!nerPipeline) { -// // this prevents multiple loading of the pipeline simultaneously which causes extreme lag -// if (loading) { -// await new Promise((resolve) => setTimeout(resolve, 500)) -// return aiNER(sentence) -// } - -// loading = true -// nerPipeline = await pipeline( -// "ner", -// "Xenova/bert-base-multilingual-cased-ner-hrl", -// ) -// loading = false -// } - -// const output: Output = await nerPipeline(sentence) - -// let currentEntity = { name: "", score: 0, type: "" } - -// output.forEach((word) => { -// const entityType = word.entity.split("-")[1] -// if (word.entity.startsWith("B-")) { -// if (currentEntity.score > 0.5) { -// entities[currentEntity.type.toLowerCase()].push( -// currentEntity.name.trim(), -// ) -// } -// currentEntity = { name: word.word, score: word.score, type: entityType } -// } else if (currentEntity.type === entityType) { -// currentEntity.name += word.word.includes("##") -// ? word.word.replace("##", "") -// : " " + word.word -// currentEntity.score *= word.score -// } -// }) - -// if (currentEntity.score > 0.5) { -// entities[currentEntity.type.toLowerCase()].push(currentEntity.name.trim()) -// } - -// return entities -// } diff --git a/packages/backend/src/checks/ai/toxic.ts b/packages/backend/src/checks/ai/toxic.ts deleted file mode 100644 index 4448d50e..00000000 --- a/packages/backend/src/checks/ai/toxic.ts +++ /dev/null @@ -1,86 +0,0 @@ -// import { pipeline } from "@xenova/transformers" - -// // One of the only libraries that supports multiple languages -// import badWords from "washyourmouthoutwithsoap/data/build.json" -// import badWordsEnExtended from "washyourmouthoutwithsoap/data/_en.json" - -// // extend with more words -// badWords.en = [...badWords.en, ...Object.keys(badWordsEnExtended)] -// const allWords = Object.values(badWords).flat() - -// let nerPipeline: any = null -// let loading = false - -// type Output = { -// label: string -// score: number -// }[] - -// const profanityListCheck = (text: string) => { -// const words = [] - -// const clean = (text: string) => text.replace(/[^a-zA-Z ]/g, "").toLowerCase() -// const tokenize = (text: string) => { -// const withPunctuation = text.replace("/ {2,}/", " ").split(" ") -// const withoutPunctuation = text -// .replace(/[^\w\s]/g, "") -// .replace("/ {2,}/", " ") -// .split(" ") - -// return ( -// withPunctuation -// .concat(withoutPunctuation) -// // otherwise some false positives with short words -// .filter((w) => w.length > 3) -// ) -// } - -// // Clean and tokenize user input -// const tokens = tokenize(clean(text)) - -// // Check against list -// for (let i in tokens) { -// if (allWords.indexOf(tokens[i]) !== -1) words.push(tokens[i]) -// } - -// return [...new Set(words)] // remove duplicates -// } - -// async function aiToxicity(sentences?: string[]): Promise { -// if (!sentences) return [] - -// const cleaned = sentences.filter((s) => s && s.length > 3) -// if (!cleaned?.length) return [] - -// // check for profanity, more efficient in some cases -// const badWords = profanityListCheck(cleaned.join(" ")) -// if (badWords.length) return badWords - -// if (!nerPipeline) { -// // this prevents multiple loading of the pipeline simultaneously which causes extreme lag -// if (loading) { -// await new Promise((resolve) => setTimeout(resolve, 500)) -// return aiToxicity(sentences) -// } - -// loading = true -// nerPipeline = await pipeline("text-classification", "Xenova/toxic-bert") -// loading = false -// } - -// const output: Output = await nerPipeline(cleaned, { topk: null }) - -// // remove duplicates and filter out low scores -// const result = [ -// ...new Set( -// output -// .flat() -// .filter((l) => l.score > 0.8) -// .map((l) => l.label), -// ), -// ] - -// return result -// } - -// export default aiToxicity diff --git a/packages/backend/src/utils/emails.ts b/packages/backend/src/utils/emails.ts index e1647239..413992a0 100644 --- a/packages/backend/src/utils/emails.ts +++ b/packages/backend/src/utils/emails.ts @@ -7,10 +7,6 @@ function extractFirstName(name: string) { } export async function sendVerifyEmail(email: string, name: string) { - if (process.env.SKIP_EMAIL_VERIFY) { - return - } - const token = await signJwt({ email }) const confirmLink = `${process.env.API_URL}/v1/users/verify-email?token=${token}` @@ -25,11 +21,19 @@ export function INVITE_EMAIL(email: string, orgName: string, link: string) { reply_to: "hello@lunary.ai", from: process.env.GENERIC_SENDER, text: `Hi, -You've been invited to join ${orgName} on Lunary. Please use the following link to accept the invitation: ${link} + +You've been invited to join ${orgName} on Lunary. + +Please click on the following link to accept the invitation: + +${link} + We're looking forward to having you on board! -Best, -The Lunary Team`, +You can reply to this email if you have any question. + +Thanks +- The Lunary team`, } } diff --git a/packages/backend/src/utils/ml.ts b/packages/backend/src/utils/ml.ts index c38ed570..9d8c4fd0 100644 --- a/packages/backend/src/utils/ml.ts +++ b/packages/backend/src/utils/ml.ts @@ -1,6 +1,9 @@ export async function callML(method: string, data: any) { const response = await fetch(`http://localhost:4242/${method}`, { method: "POST", + // For example at the first ML calls, it needs to DL the models, so it can take a while + // So add timeout + signal: AbortSignal.timeout(5000), headers: { "Content-Type": "application/json", }, diff --git a/packages/db/0006.sql b/packages/db/0006.sql index 3be8c13b..3fdd9300 100644 --- a/packages/db/0006.sql +++ b/packages/db/0006.sql @@ -1,4 +1,5 @@ alter table account add column if not exists single_use_token text; + create table if not exists account_project ( account_id uuid references account(id) on delete cascade, project_id uuid references project(id) on delete cascade, diff --git a/packages/frontend/components/blocks/CopyText.tsx b/packages/frontend/components/blocks/CopyText.tsx index 6d7c3191..bd7ce0be 100644 --- a/packages/frontend/components/blocks/CopyText.tsx +++ b/packages/frontend/components/blocks/CopyText.tsx @@ -1,4 +1,11 @@ -import { ActionIcon, Code, CopyButton, Group, Tooltip } from "@mantine/core" +import { + ActionIcon, + Code, + CopyButton, + Group, + Input, + Tooltip, +} from "@mantine/core" import { IconCheck, IconCopy } from "@tabler/icons-react" export const SuperCopyButton = ({ value }) => ( @@ -27,3 +34,13 @@ export default function CopyText({ c = "violet", value }) { ) } + +export const CopyInput = ({ value, ...props }) => ( + } + {...props} + /> +) diff --git a/packages/frontend/components/blocks/SettingsCard.tsx b/packages/frontend/components/blocks/SettingsCard.tsx new file mode 100644 index 00000000..ca424389 --- /dev/null +++ b/packages/frontend/components/blocks/SettingsCard.tsx @@ -0,0 +1,23 @@ +import { Card, Stack, Title } from "@mantine/core" + +// so we can have an harmonized title for all cards +export function SettingsCard({ + title, + children, + align, + gap = "lg", +}: { + title + children: React.ReactNode + align?: string + gap?: string +}) { + return ( + + + {title} + {children} + + + ) +} diff --git a/packages/frontend/components/blocks/SocialProof.tsx b/packages/frontend/components/blocks/SocialProof.tsx index 9f327e92..7b3fda96 100644 --- a/packages/frontend/components/blocks/SocialProof.tsx +++ b/packages/frontend/components/blocks/SocialProof.tsx @@ -22,7 +22,7 @@ export default function SocialProof() { span fw="bolder" > - 1000+ + 1500+ {" "} AI devs build better apps diff --git a/packages/frontend/components/layout/Analytics.tsx b/packages/frontend/components/layout/Analytics.tsx index 3d1e75cb..7763a146 100644 --- a/packages/frontend/components/layout/Analytics.tsx +++ b/packages/frontend/components/layout/Analytics.tsx @@ -3,7 +3,7 @@ import { useEffect, Component } from "react" import Script from "next/script" import { PostHogProvider } from "posthog-js/react" -import PlausibleProvider from "next-plausible" + import posthog from "posthog-js" import analytics from "@/utils/analytics" @@ -37,32 +37,23 @@ export default function AnalyticsWrapper({ children }) { return ( <> {process.env.NEXT_PUBLIC_CRISP_ID && } - - {process.env.NEXT_PUBLIC_CUSTOM_SCRIPT && ( -