+
+
+
+
setStep(1)}
+ >
+
+
+ 1. {t("onboarding.steps.1.title")}
+
+
+
+
+ {t("onboarding.steps.1.description")}
+
+ setStep(2)}
+ className="border-dashed text-xs"
+ />
+
+
+
+
= 2 ? "border-primary" : "border-border",
+ )}
+ />
+
+
+
setStep(2)}
+ >
+
+
+ 2. {t("onboarding.steps.2.title")}
+
+
+
+
+ {step === 2 &&
}
+
+
+ {t("onboarding.steps.2.description")}
+
+
+
+
+
+
+
+ {t("onboarding.info.description")}{" "}
+
+ {t("onboarding.info.link")}
+ {" "}
+ {t("onboarding.info.description_2")}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings-card.tsx b/apps/web/src/components/settings-card.tsx
index 325964ad..13c618f8 100644
--- a/apps/web/src/components/settings-card.tsx
+++ b/apps/web/src/components/settings-card.tsx
@@ -53,7 +53,7 @@ export function SettingsCard({
onSave?: (value: string) => void;
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
- options?: { label: string; value: string }[];
+ options?: { label: string; value: string; icon?: () => JSX.Element }[];
placeholder?: string;
isLoading?: boolean;
validate?: "email" | "url" | "number" | "password" | "text";
@@ -92,6 +92,64 @@ export function SettingsCard({
}
};
+ const handleCheckedChange = async (checked: boolean) => {
+ try {
+ setIsSaving(true);
+ await onCheckedChange?.(checked);
+
+ toast.success(t("settings.saved"), {
+ description: t("settings.savedDescription"),
+ });
+ } catch (error) {
+ if (error instanceof TRPCClientError) {
+ if (error.data?.code === "FORBIDDEN") {
+ toast.error(t("settings.permissionDenied"), {
+ description: t("settings.permissionDeniedDescription"),
+ });
+ } else {
+ toast.error(t("settings.error"), {
+ description: t("settings.errorDescription"),
+ });
+ }
+ } else {
+ toast.error(t("settings.error"), {
+ description: t("settings.errorDescription"),
+ });
+ }
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleSelectChange = async (value: string) => {
+ try {
+ setIsSaving(true);
+ await onChange?.(value);
+
+ toast.success(t("settings.saved"), {
+ description: t("settings.savedDescription"),
+ });
+ } catch (error) {
+ if (error instanceof TRPCClientError) {
+ if (error.data?.code === "FORBIDDEN") {
+ toast.error(t("settings.permissionDenied"), {
+ description: t("settings.permissionDeniedDescription"),
+ });
+ } else {
+ toast.error(t("settings.error"), {
+ description: t("settings.errorDescription"),
+ });
+ }
+ } else {
+ toast.error(t("settings.error"), {
+ description: t("settings.errorDescription"),
+ });
+ }
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
if (isLoading) {
return (
@@ -128,25 +186,38 @@ export function SettingsCard({
{type === "switch" && (
{
- onCheckedChange?.(!!checked);
- toast.success(t("settings.saved"), {
- description: t("settings.savedDescription"),
- });
+ onCheckedChange={(value) => {
+ handleCheckedChange?.(!!value);
}}
/>
)}
{type === "select" && options && (
-
-
- {tab === "project" && (
-
- )}
- {tab === "team" && (
-
- )}
@@ -71,6 +66,14 @@ export function Settings() {
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/src/components/settings/provider.tsx b/apps/web/src/components/settings/provider.tsx
new file mode 100644
index 00000000..0f9965b9
--- /dev/null
+++ b/apps/web/src/components/settings/provider.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { SettingsCard } from "@/components/settings-card";
+import { useI18n } from "@/locales/client";
+import { trpc } from "@/trpc/client";
+import { useParams } from "next/navigation";
+import { toast } from "sonner";
+import { Icons } from "../ui/icons";
+
+const OFUSCATED_API_KEY = "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
+
+export function ProviderSettings() {
+ const t = useI18n();
+ const { organization, project } = useParams();
+
+ const trpcUtils = trpc.useUtils();
+
+ const [projectData] = trpc.project.getBySlug.useSuspenseQuery({
+ slug: project as string,
+ organizationId: organization as string,
+ });
+
+ const updateMutation = trpc.project.updateSettings.useMutation({
+ onSuccess: () => {
+ toast.success(t("settings.provider.updateSuccess"));
+ trpcUtils.project.getBySlug.invalidate({
+ slug: project as string,
+ organizationId: organization as string,
+ });
+ },
+ onError: (error) => {
+ toast.error(t("settings.provider.updateError"));
+ },
+ });
+
+ const providers = [
+ { icon: Icons.OpenAI, value: "openai", label: "OpenAI" },
+ { icon: Icons.xAI, value: "xai", label: "xAI" },
+ ];
+
+ const models = {
+ openai: [
+ { value: "gpt-4-turbo", label: "GPT-4 Turbo (Default)" },
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-4o", label: "GPT-4o" },
+ { value: "gpt-4o-mini", label: "GPT-4o Mini" },
+ { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
+ ],
+ xai: [{ value: "grok", label: "Grok" }],
+ };
+
+ return (
+
+ {
+ await updateMutation.mutateAsync({
+ slug: project as string,
+ organizationId: organization as string,
+ settings: {
+ provider: value,
+ model: models[value as keyof typeof models].at(0)?.value,
+ },
+ });
+ }}
+ />
+
+ {
+ await updateMutation.mutateAsync({
+ slug: project as string,
+ organizationId: organization as string,
+ settings: {
+ model: value,
+ },
+ });
+ }}
+ />
+
+ {
+ await updateMutation.mutateAsync({
+ slug: project as string,
+ organizationId: organization as string,
+ settings: {
+ providerApiKey: value,
+ },
+ });
+ }}
+ />
+
+ );
+}
diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx
index 8cfc9f17..749d3582 100644
--- a/apps/web/src/components/sidebar.tsx
+++ b/apps/web/src/components/sidebar.tsx
@@ -44,7 +44,7 @@ export function Sidebar() {
];
return (
-
+
-
+
{navigation.map((item, index) => (
{
+ await trpcUtils.project.getBySlug.cancel();
+
+ const previousData = trpcUtils.project.getBySlug.getData({
+ slug: project as string,
+ organizationId: organization as string,
+ });
+
+ // Optimistically update to the new value
+ trpcUtils.project.getBySlug.setData(
+ { slug: project as string, organizationId: organization as string },
+ // @ts-ignore
+ (old) => {
+ if (!old) return;
+
+ return {
+ ...old,
+ settings: {
+ ...old?.settings,
+ ...settings,
+ },
+ };
+ },
+ );
+
+ return { previousData };
+ },
+ onError: (_, __, context) => {
+ trpcUtils.project.getBySlug.setData(
+ { slug: project as string, organizationId: organization as string },
+ context?.previousData,
+ );
+ },
+ onSettled: () => {
+ trpcUtils.project.getBySlug.invalidate({
+ slug: project as string,
+ organizationId: organization as string,
+ });
+ },
+ });
+
+ const handleUpdate = async (settings: Record) => {
+ await updateMutation.mutateAsync({
+ slug: project as string,
+ organizationId: organization as string,
+ settings,
+ });
+ };
return (
-
{
+ await handleUpdate({ translationMemory: checked });
+ }}
/>
+
{
+ await handleUpdate({ qualityChecks: checked });
+ }}
/>
+
{
+ await handleUpdate({ contextDetection: checked });
+ }}
/>
@@ -40,7 +110,7 @@ export function Tuning() {
title={t("tuning.lengthControl.title")}
description={t("tuning.lengthControl.description")}
type="select"
- value="flexible"
+ value={projectData.settings?.lengthControl ?? "flexible"}
options={[
{
label: t("tuning.lengthControl.options.flexible"),
@@ -50,23 +120,70 @@ export function Tuning() {
{ label: t("tuning.lengthControl.options.exact"), value: "exact" },
{ label: t("tuning.lengthControl.options.loose"), value: "loose" },
]}
+ onChange={async (value) => {
+ await handleUpdate({ lengthControl: value });
+ }}
/>
+
{
+ await handleUpdate({ inclusiveLanguage: checked });
+ }}
/>
+
{
+ await handleUpdate({ formality: value });
+ }}
/>
+ {
+ await handleUpdate({ toneOfVoice: value });
+ }}
+ />
+
+
+
+
{
+ await handleUpdate({ brandName: value });
+ }}
/>
{
+ await handleUpdate({ brandVoice: value });
+ }}
/>
-
-
-
{
+ await handleUpdate({ emotiveIntent: value });
+ }}
/>
+
+
+
+
{
+ await handleUpdate({ domainExpertise: value });
+ }}
/>
+
{
+ await handleUpdate({ idioms: checked });
+ }}
/>
);
diff --git a/apps/web/src/components/ui/icons.tsx b/apps/web/src/components/ui/icons.tsx
new file mode 100644
index 00000000..13f3e4e5
--- /dev/null
+++ b/apps/web/src/components/ui/icons.tsx
@@ -0,0 +1,44 @@
+export const Icons = {
+ xAI: () => (
+
+ ),
+ OpenAI: () => (
+
+ ),
+};
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 0a953c01..1508e381 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
span]:line-clamp-1",
+ "flex h-10 w-full items-center justify-between border border-border px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
diff --git a/apps/web/src/db/queries/project-settings.ts b/apps/web/src/db/queries/project-settings.ts
deleted file mode 100644
index 149f401e..00000000
--- a/apps/web/src/db/queries/project-settings.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { eq } from "drizzle-orm";
-import { db } from "..";
-import { projectSettings } from "../schema";
-
-export const getProjectSettings = async (projectId: string) => {
- return db
- .select()
- .from(projectSettings)
- .where(eq(projectSettings.projectId, projectId))
- .get();
-};
-
-export const updateProjectSettings = async ({
- projectId,
- cache,
- context,
- temperature,
- instructions,
- memory,
- grammar,
-}: {
- projectId: string;
- cache?: boolean;
- context?: boolean;
- temperature?: number;
- instructions?: string;
- memory?: boolean;
- grammar?: boolean;
-}) => {
- return db
- .update(projectSettings)
- .set({
- cache,
- context,
- temperature,
- instructions,
- memory,
- grammar,
- })
- .where(eq(projectSettings.projectId, projectId))
- .returning()
- .get();
-};
-
-export const createProjectSettings = async ({
- projectId,
- cache = true,
- context = true,
- temperature = 0,
- instructions,
- memory = true,
- grammar = true,
-}: {
- projectId: string;
- cache?: boolean;
- context?: boolean;
- temperature?: number;
- instructions?: string;
- memory?: boolean;
- grammar?: boolean;
-}) => {
- return db
- .insert(projectSettings)
- .values({
- projectId,
- cache,
- context,
- temperature,
- instructions,
- memory,
- grammar,
- })
- .returning()
- .get();
-};
diff --git a/apps/web/src/db/queries/project.ts b/apps/web/src/db/queries/project.ts
index dec05d88..9f1fbdbe 100644
--- a/apps/web/src/db/queries/project.ts
+++ b/apps/web/src/db/queries/project.ts
@@ -1,7 +1,8 @@
+import { encrypt } from "@/lib/crypto";
import { and, eq } from "drizzle-orm";
import slugify from "slugify";
import { db } from "..";
-import { projects } from "../schema";
+import { projectSettings, projects } from "../schema";
export const createProject = async ({
name,
@@ -63,13 +64,25 @@ export const getProjectBySlug = async ({
slug: string;
organizationId: string;
}) => {
- return db
- .select()
+ const project = db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ slug: projects.slug,
+ description: projects.description,
+ organizationId: projects.organizationId,
+ createdAt: projects.createdAt,
+ updatedAt: projects.updatedAt,
+ settings: projectSettings,
+ })
.from(projects)
+ .leftJoin(projectSettings, eq(projects.id, projectSettings.projectId))
.where(
and(eq(projects.slug, slug), eq(projects.organizationId, organizationId)),
)
.get();
+
+ return project;
};
export const getProjectById = async ({
@@ -91,3 +104,62 @@ export const getProjectByOrganizationId = async ({
.where(eq(projects.organizationId, organizationId))
.get();
};
+
+export const updateProjectSettings = async ({
+ slug,
+ organizationId,
+ settings,
+}: {
+ slug: string;
+ organizationId: string;
+ settings: {
+ provider?: string;
+ model?: string;
+ providerApiKey?: string;
+ };
+}) => {
+ const project = await db
+ .select({
+ id: projects.id,
+ })
+ .from(projects)
+ .where(
+ and(eq(projects.slug, slug), eq(projects.organizationId, organizationId)),
+ )
+ .get();
+
+ if (!project) return null;
+
+ const projectId = project.id;
+ const whereClause = and(
+ eq(projectSettings.projectId, projectId),
+ eq(projectSettings.organizationId, organizationId),
+ );
+
+ const settingsToUpdate = {
+ ...settings,
+ };
+
+ if (settings.providerApiKey) {
+ settingsToUpdate.providerApiKey = await encrypt(settings.providerApiKey);
+ }
+
+ const updated = await db
+ .update(projectSettings)
+ .set(settingsToUpdate)
+ .where(whereClause)
+ .returning()
+ .get();
+
+ if (updated) return updated;
+
+ return db
+ .insert(projectSettings)
+ .values({
+ ...settingsToUpdate,
+ projectId,
+ organizationId,
+ })
+ .returning()
+ .get();
+};
diff --git a/apps/web/src/db/queries/translate.ts b/apps/web/src/db/queries/translate.ts
index 2ed7d20c..f40cb901 100644
--- a/apps/web/src/db/queries/translate.ts
+++ b/apps/web/src/db/queries/translate.ts
@@ -10,7 +10,7 @@ export const createTranslation = async ({
translations: translationItems,
}: {
projectId: string;
- userId: string;
+ userId?: string;
organizationId: string;
sourceFormat: string;
translations: {
diff --git a/apps/web/src/db/schema.ts b/apps/web/src/db/schema.ts
index ff8a039f..783d9ef7 100644
--- a/apps/web/src/db/schema.ts
+++ b/apps/web/src/db/schema.ts
@@ -2,7 +2,6 @@ import { createId } from "@paralleldrive/cuid2";
import {
index,
integer,
- real,
sqliteTable,
text,
uniqueIndex,
@@ -191,12 +190,81 @@ export const projectSettings = sqliteTable(
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
- cache: integer("cache", { mode: "boolean" }).notNull().default(true),
- context: integer("context", { mode: "boolean" }).notNull().default(true),
- temperature: real("temperature").notNull().default(0),
- instructions: text("instructions"),
- memory: integer("memory", { mode: "boolean" }).notNull().default(true),
- grammar: integer("grammar", { mode: "boolean" }).notNull().default(true),
+ organizationId: text("organization_id")
+ .notNull()
+ .references(() => organizations.id, { onDelete: "cascade" }),
+
+ // Tuning start
+ translationMemory: integer("translation_memory", { mode: "boolean" })
+ .notNull()
+ .default(true),
+ qualityChecks: integer("quality_checks", { mode: "boolean" })
+ .notNull()
+ .default(true),
+ contextDetection: integer("context_detection", { mode: "boolean" })
+ .notNull()
+ .default(true),
+ lengthControl: text("length_control", {
+ enum: ["flexible", "strict", "exact", "loose"],
+ })
+ .notNull()
+ .default("flexible"),
+ inclusiveLanguage: integer("inclusive_language", { mode: "boolean" })
+ .notNull()
+ .default(true),
+ formality: text("formality", { enum: ["casual", "formal", "neutral"] })
+ .notNull()
+ .default("casual"),
+ toneOfVoice: text("tone_of_voice", {
+ enum: [
+ "casual",
+ "formal",
+ "friendly",
+ "professional",
+ "playful",
+ "serious",
+ "confident",
+ "humble",
+ "direct",
+ "diplomatic",
+ ],
+ })
+ .notNull()
+ .default("casual"),
+ brandName: text("brand_name"),
+ brandVoice: text("brand_voice"),
+ emotiveIntent: text("emotive_intent", {
+ enum: [
+ "neutral",
+ "positive",
+ "empathetic",
+ "professional",
+ "friendly",
+ "enthusiastic",
+ ],
+ })
+ .notNull()
+ .default("neutral"),
+ idioms: integer("idioms", { mode: "boolean" }).notNull().default(true),
+ terminology: text("terminology"),
+ domainExpertise: text("domain_expertise", {
+ enum: [
+ "general",
+ "technical",
+ "medical",
+ "legal",
+ "financial",
+ "marketing",
+ "academic",
+ ],
+ })
+ .notNull()
+ .default("general"),
+ // Tuning end
+
+ provider: text("provider").notNull().default("openai"),
+ model: text("model").notNull().default("gpt-4-turbo"),
+ providerApiKey: text("provider_api_key"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
diff --git a/apps/web/src/lib/crypto.ts b/apps/web/src/lib/crypto.ts
new file mode 100644
index 00000000..1dbb7419
--- /dev/null
+++ b/apps/web/src/lib/crypto.ts
@@ -0,0 +1,76 @@
+const SECRET_KEY = process.env.ENCRYPTION_SECRET!;
+const ALGORITHM = { name: "AES-GCM", length: 256 };
+const IV_LENGTH = 12;
+const SALT_LENGTH = 16;
+const ITERATIONS = 100000;
+
+async function deriveKey(
+ password: string,
+ salt: Uint8Array,
+): Promise {
+ const encoder = new TextEncoder();
+ const passwordBuffer = encoder.encode(password);
+
+ const baseKey = await crypto.subtle.importKey(
+ "raw",
+ passwordBuffer,
+ "PBKDF2",
+ false,
+ ["deriveKey"],
+ );
+
+ return crypto.subtle.deriveKey(
+ {
+ name: "PBKDF2",
+ salt,
+ iterations: ITERATIONS,
+ hash: "SHA-256",
+ },
+ baseKey,
+ ALGORITHM,
+ false,
+ ["encrypt", "decrypt"],
+ );
+}
+
+export async function encrypt(text: string): Promise {
+ const encoder = new TextEncoder();
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
+ const key = await deriveKey(SECRET_KEY, salt);
+
+ const encrypted = await crypto.subtle.encrypt(
+ {
+ name: ALGORITHM.name,
+ iv,
+ },
+ key,
+ encoder.encode(text),
+ );
+
+ const result = new Uint8Array([...iv, ...salt, ...new Uint8Array(encrypted)]);
+
+ return btoa(String.fromCharCode(...result));
+}
+
+export async function decrypt(encryptedText: string): Promise {
+ const decoder = new TextDecoder();
+ const buffer = Uint8Array.from(atob(encryptedText), (c) => c.charCodeAt(0));
+
+ const iv = buffer.slice(0, IV_LENGTH);
+ const salt = buffer.slice(IV_LENGTH, IV_LENGTH + SALT_LENGTH);
+ const encrypted = buffer.slice(IV_LENGTH + SALT_LENGTH);
+
+ const key = await deriveKey(SECRET_KEY, salt);
+
+ const decrypted = await crypto.subtle.decrypt(
+ {
+ name: ALGORITHM.name,
+ iv,
+ },
+ key,
+ encrypted,
+ );
+
+ return decoder.decode(decrypted);
+}
diff --git a/apps/web/src/lib/translators/prompt.ts b/apps/web/src/lib/translators/prompt.ts
index 228bf571..4b813b8a 100644
--- a/apps/web/src/lib/translators/prompt.ts
+++ b/apps/web/src/lib/translators/prompt.ts
@@ -1,3 +1,4 @@
+import type { projectSettings } from "@/db/schema";
import type { PromptOptions } from "./types";
export const baseRequirements = `
@@ -15,3 +16,47 @@ export function createBasePrompt(text: string, options: PromptOptions) {
Task: Translate the content below from ${options.contentLocale} to ${options.targetLocale}.
${text}`;
}
+
+export function createTuningPrompt(
+ settings: Partial,
+) {
+ const tuningInstructions = [
+ // Style and tone settings
+ settings.formality && `- Use ${settings.formality} language style`,
+ settings.toneOfVoice &&
+ `- Maintain a ${settings.toneOfVoice} tone of voice`,
+ settings.emotiveIntent &&
+ `- Convey a ${settings.emotiveIntent} emotional tone`,
+
+ // Brand-specific settings
+ settings.brandName &&
+ `- Use "${settings.brandName}" consistently for brand references`,
+ settings.brandVoice &&
+ `- Follow brand voice guidelines: ${settings.brandVoice}`,
+
+ // Technical settings
+ settings.lengthControl &&
+ `- Apply ${settings.lengthControl} length control`,
+ settings.domainExpertise &&
+ `- Use terminology appropriate for ${settings.domainExpertise} domain`,
+ settings.terminology &&
+ `- Follow specific terminology: ${settings.terminology}`,
+
+ // Feature flags
+ settings.translationMemory &&
+ "- Maintain consistency with previous translations",
+ settings.qualityChecks &&
+ "- Ensure high-quality output with proper grammar and spelling",
+ settings.contextDetection &&
+ "- Consider surrounding context for accurate translations",
+ settings.inclusiveLanguage &&
+ "- Use inclusive and non-discriminatory language",
+ settings.idioms && "- Adapt idioms appropriately for target culture",
+ ]
+ .filter(Boolean)
+ .join("\n");
+
+ return tuningInstructions
+ ? `\nAdditional Requirements:\n${tuningInstructions}`
+ : "";
+}
diff --git a/apps/web/src/locales/en.ts b/apps/web/src/locales/en.ts
index 447a0c11..acf88275 100644
--- a/apps/web/src/locales/en.ts
+++ b/apps/web/src/locales/en.ts
@@ -26,6 +26,8 @@ export default {
},
activity: {
title: "Activity",
+ loading: "Loading",
+ loadMore: "Show more",
},
features: {
title: "Features",
@@ -186,6 +188,30 @@ export default {
project: "Project",
account: "Account",
team: "Team",
+ provider: "Provider",
+ billing: "Billing",
+ },
+ provider: {
+ updateSuccess: "Settings updated successfully",
+ updateError: "Failed to update settings",
+ translationProvider: {
+ title: "Translation Provider",
+ description:
+ "Choose your preferred AI service for generating translations. Each provider offers different capabilities and pricing.",
+ placeholder: "Select a provider",
+ },
+ languageModel: {
+ title: "Language Model",
+ description:
+ "Select the AI model that best balances quality and speed for your translation needs. More powerful models may be slower but produce better results.",
+ placeholder: "Select a model",
+ },
+ apiKey: {
+ title: "Provider API Key",
+ description:
+ "Enter your API key to authenticate with your chosen provider. Keep this key secure - we encrypt it before storing.",
+ placeholder: "Enter your API key",
+ },
},
addProject: "Create project",
project: {
@@ -217,6 +243,7 @@ export default {
description: "Manage your team's billing plan",
free: "Free",
pro: "Pro",
+ unlimited: "Unlimited",
},
apiKey: {
title: "Team API Key",
@@ -396,4 +423,28 @@ export default {
"11": "Nov",
"12": "Dec",
},
+ onboarding: {
+ steps: {
+ "1": {
+ title: "Setup Languine CLI",
+ description:
+ "Install and configure the Languine CLI to manage translations",
+ },
+ "2": {
+ title: "Push Translations",
+ description: "Waiting for the translations to be pushed",
+ },
+ "3": {
+ title: "Documentation",
+ description:
+ "Check out our documentation for detailed guides and best practices",
+ link: "documentation",
+ },
+ },
+ info: {
+ description: "Need help? Check out our",
+ link: "documentation",
+ description_2: "for detailed guides and best practices.",
+ },
+ },
} as const;
diff --git a/apps/web/src/trpc/init.ts b/apps/web/src/trpc/init.ts
index 4665cd79..4e0a352b 100644
--- a/apps/web/src/trpc/init.ts
+++ b/apps/web/src/trpc/init.ts
@@ -1,8 +1,50 @@
+import { db } from "@/db";
+import { users } from "@/db/schema";
+import { organizations } from "@/db/schema";
import { authClient } from "@/lib/auth/client";
import { TRPCError, initTRPC } from "@trpc/server";
+import { eq } from "drizzle-orm";
import superjson from "superjson";
+async function validateApiKey(
+ apiKey: string,
+): Promise<{ authenticatedId: string; type: "user" | "organization" } | null> {
+ if (apiKey.startsWith("org_")) {
+ const org = await db
+ .select()
+ .from(organizations)
+ .where(eq(organizations.apiKey, apiKey))
+ .get();
+ if (org) {
+ return { authenticatedId: org.id, type: "organization" };
+ }
+ } else {
+ const user = await db
+ .select()
+ .from(users)
+ .where(eq(users.apiKey, apiKey))
+ .get();
+ if (user) {
+ return { authenticatedId: user.id, type: "user" };
+ }
+ }
+ return null;
+}
+
export const createTRPCContext = async (opts: { headers: Headers }) => {
+ const apiKey = opts.headers.get("x-api-key");
+
+ // Either a user or organization
+ if (apiKey) {
+ const result = await validateApiKey(apiKey);
+ if (result) {
+ return {
+ authenticatedId: result.authenticatedId,
+ type: result.type,
+ };
+ }
+ }
+
const session = await authClient.getSession({
fetchOptions: {
headers: opts.headers,
@@ -10,7 +52,8 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
});
return {
- user: session?.data?.user,
+ authenticatedId: session?.data?.user?.id,
+ type: "user",
};
};
@@ -23,15 +66,16 @@ export const createCallerFactory = t.createCallerFactory;
export const createTRPCRouter = t.router;
export const protectedProcedure = t.procedure.use(async (opts) => {
- const { user } = opts.ctx;
+ const { authenticatedId, type } = opts.ctx;
- if (!user) {
+ if (!authenticatedId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return opts.next({
ctx: {
- user,
+ authenticatedId,
+ type,
},
});
});
diff --git a/apps/web/src/trpc/middlewares/ratelimits.ts b/apps/web/src/trpc/middlewares/ratelimits.ts
index 1b12f6b9..28e8839a 100644
--- a/apps/web/src/trpc/middlewares/ratelimits.ts
+++ b/apps/web/src/trpc/middlewares/ratelimits.ts
@@ -9,14 +9,14 @@ const ratelimit = new Ratelimit({
});
export const rateLimitMiddleware = t.middleware(async ({ ctx, next, path }) => {
- if (!ctx.user?.id) {
+ if (!ctx.authenticatedId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in",
});
}
- const identifier = `${ctx.user.id}:${path}`;
+ const identifier = `${ctx.authenticatedId}:${path}`;
const { success } = await ratelimit.limit(identifier);
diff --git a/apps/web/src/trpc/permissions/organization.ts b/apps/web/src/trpc/permissions/organization.ts
index 3e8172eb..433e4f00 100644
--- a/apps/web/src/trpc/permissions/organization.ts
+++ b/apps/web/src/trpc/permissions/organization.ts
@@ -4,9 +4,14 @@ import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { t } from "../init";
+/**
+ * Middleware to check if the authenticated user is a member of the specified organization.
+ * Also allows access if the request is made with an organization's API key.
+ */
export const isOrganizationMember = t.middleware(
async ({ ctx, next, input }) => {
- if (!ctx.user) {
+ // Ensure user is authenticated
+ if (!ctx.authenticatedId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in",
@@ -15,6 +20,15 @@ export const isOrganizationMember = t.middleware(
const typedInput = input as { organizationId: string };
+ // Allow access if using organization's API key
+ if (
+ ctx.type === "organization" &&
+ ctx.authenticatedId === typedInput.organizationId
+ ) {
+ return next();
+ }
+
+ // Check if user is a member of the organization
const result = await db
.select({
member: members,
@@ -24,7 +38,7 @@ export const isOrganizationMember = t.middleware(
members,
and(
eq(members.organizationId, typedInput.organizationId),
- eq(members.userId, ctx.user.id),
+ eq(members.userId, ctx.authenticatedId),
),
)
.where(eq(organizations.id, typedInput.organizationId))
@@ -37,7 +51,8 @@ export const isOrganizationMember = t.middleware(
});
}
- if (!result.member) {
+ // Block access if not a member and not using org API key
+ if (!result.member && ctx.type !== "organization") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
@@ -48,9 +63,14 @@ export const isOrganizationMember = t.middleware(
},
);
+/**
+ * Middleware to check if the authenticated user is an owner of the specified organization.
+ * Also allows access if the request is made with an organization's API key.
+ */
export const isOrganizationOwner = t.middleware(
async ({ ctx, next, input }) => {
- if (!ctx.user) {
+ // Ensure user is authenticated
+ if (!ctx.authenticatedId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in",
@@ -59,6 +79,15 @@ export const isOrganizationOwner = t.middleware(
const typedInput = input as { organizationId: string };
+ // Allow access if using organization's API key
+ if (
+ ctx.type === "organization" &&
+ ctx.authenticatedId === typedInput.organizationId
+ ) {
+ return next();
+ }
+
+ // Check if user is an owner of the organization
const result = await db
.select({
member: members,
@@ -68,7 +97,7 @@ export const isOrganizationOwner = t.middleware(
members,
and(
eq(members.organizationId, typedInput.organizationId),
- eq(members.userId, ctx.user.id),
+ eq(members.userId, ctx.authenticatedId),
),
)
.where(eq(organizations.id, typedInput.organizationId))
@@ -81,7 +110,8 @@ export const isOrganizationOwner = t.middleware(
});
}
- if (result.member?.role !== "owner") {
+ // Block access if not an owner and not using org API key
+ if (result.member?.role !== "owner" && ctx.type !== "organization") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not an owner of this organization",
diff --git a/apps/web/src/trpc/permissions/project.ts b/apps/web/src/trpc/permissions/project.ts
deleted file mode 100644
index 0e228165..00000000
--- a/apps/web/src/trpc/permissions/project.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { db } from "@/db";
-import { members, organizations, projects } from "@/db/schema";
-import { TRPCError } from "@trpc/server";
-import { and, eq } from "drizzle-orm";
-import { t } from "../init";
-
-export const isProjectMember = t.middleware(async ({ ctx, next, input }) => {
- if (!ctx.user) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "You must be logged in",
- });
- }
-
- const typedInput = input as { slug: string; organizationId: string };
-
- const result = await db
- .select({
- member: members,
- project: projects,
- })
- .from(projects)
- .innerJoin(organizations, eq(organizations.id, typedInput.organizationId))
- .leftJoin(
- members,
- and(
- eq(members.organizationId, typedInput.organizationId),
- eq(members.userId, ctx.user.id),
- ),
- )
- .where(
- and(
- eq(projects.slug, typedInput.slug),
- eq(projects.organizationId, typedInput.organizationId),
- ),
- )
- .get();
-
- if (!result) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Project not found",
- });
- }
-
- if (!result.member) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "You don't have access to this project",
- });
- }
-
- return next();
-});
diff --git a/apps/web/src/trpc/routers/analytics.ts b/apps/web/src/trpc/routers/analytics.ts
index f1dbd136..761840fb 100644
--- a/apps/web/src/trpc/routers/analytics.ts
+++ b/apps/web/src/trpc/routers/analytics.ts
@@ -1,6 +1,7 @@
import { getAnalytics } from "@/db/queries/analytics";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../init";
+import { isOrganizationMember } from "../permissions/organization";
export const analyticsRouter = createTRPCRouter({
getProjectStats: protectedProcedure
@@ -12,7 +13,7 @@ export const analyticsRouter = createTRPCRouter({
organizationId: z.string(),
}),
)
- // .use(isProjectMember)
+ .use(isOrganizationMember)
.query(async ({ input }) => {
const analytics = await getAnalytics({
projectSlug: input.projectSlug,
diff --git a/apps/web/src/trpc/routers/organization.ts b/apps/web/src/trpc/routers/organization.ts
index 60c8676d..a6f864a4 100644
--- a/apps/web/src/trpc/routers/organization.ts
+++ b/apps/web/src/trpc/routers/organization.ts
@@ -42,7 +42,7 @@ export const organizationRouter = createTRPCRouter({
}),
getAll: protectedProcedure.input(z.void()).query(async ({ ctx }) => {
- return getAllOrganizationsWithProjects(ctx.user.id);
+ return getAllOrganizationsWithProjects(ctx.authenticatedId);
}),
getMembers: protectedProcedure
@@ -69,7 +69,7 @@ export const organizationRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const org = await createOrganization({
name: input.name,
- userId: ctx.user.id,
+ userId: ctx.authenticatedId,
});
if (!org) {
@@ -212,7 +212,7 @@ export const organizationRouter = createTRPCRouter({
and(
eq(members.organizationId, input.organizationId),
eq(members.role, "owner"),
- ne(members.userId, ctx.user.id),
+ ne(members.userId, ctx.authenticatedId),
),
)
.all();
@@ -224,7 +224,7 @@ export const organizationRouter = createTRPCRouter({
});
}
- return leaveOrganization(input.organizationId, ctx.user.id);
+ return leaveOrganization(input.organizationId, ctx.authenticatedId);
}),
updateApiKey: protectedProcedure
diff --git a/apps/web/src/trpc/routers/project.ts b/apps/web/src/trpc/routers/project.ts
index 4c7cc9b9..5a757c0f 100644
--- a/apps/web/src/trpc/routers/project.ts
+++ b/apps/web/src/trpc/routers/project.ts
@@ -3,13 +3,17 @@ import {
deleteProject,
getProjectBySlug,
updateProject,
+ updateProjectSettings,
} from "@/db/queries/project";
+import { decrypt } from "@/lib/crypto";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../init";
import { rateLimitMiddleware } from "../middlewares/ratelimits";
-import { isOrganizationOwner } from "../permissions/organization";
-import { isProjectMember } from "../permissions/project";
+import {
+ isOrganizationMember,
+ isOrganizationOwner,
+} from "../permissions/organization";
export const projectRouter = createTRPCRouter({
getBySlug: protectedProcedure
@@ -19,10 +23,16 @@ export const projectRouter = createTRPCRouter({
organizationId: z.string(),
}),
)
- .use(isProjectMember)
+ .use(isOrganizationMember)
.query(async ({ input }) => {
const project = await getProjectBySlug(input);
+ if (project?.settings?.providerApiKey) {
+ project.settings.providerApiKey = await decrypt(
+ project.settings.providerApiKey,
+ );
+ }
+
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
@@ -77,6 +87,80 @@ export const projectRouter = createTRPCRouter({
return project;
}),
+ updateSettings: protectedProcedure
+ .input(
+ z.object({
+ slug: z.string(),
+ organizationId: z.string(),
+ settings: z.object({
+ provider: z.string().optional(),
+ model: z.string().optional(),
+ providerApiKey: z.string().optional(),
+ translationMemory: z.boolean().optional(),
+ qualityChecks: z.boolean().optional(),
+ contextDetection: z.boolean().optional(),
+ lengthControl: z
+ .enum(["flexible", "strict", "exact", "loose"])
+ .optional(),
+ inclusiveLanguage: z.boolean().optional(),
+ formality: z.enum(["casual", "formal", "neutral"]).optional(),
+ toneOfVoice: z
+ .enum([
+ "casual",
+ "formal",
+ "friendly",
+ "professional",
+ "playful",
+ "serious",
+ "confident",
+ "humble",
+ "direct",
+ "diplomatic",
+ ])
+ .optional(),
+ brandName: z.string().optional(),
+ brandVoice: z.string().optional(),
+ emotiveIntent: z
+ .enum([
+ "neutral",
+ "positive",
+ "empathetic",
+ "professional",
+ "friendly",
+ "enthusiastic",
+ ])
+ .optional(),
+ idioms: z.boolean().optional(),
+ terminology: z.string().optional(),
+ domainExpertise: z
+ .enum([
+ "general",
+ "technical",
+ "medical",
+ "legal",
+ "financial",
+ "marketing",
+ "academic",
+ ])
+ .optional(),
+ }),
+ }),
+ )
+ .use(rateLimitMiddleware)
+ .use(isOrganizationOwner)
+ .mutation(async ({ input }) => {
+ const project = await updateProjectSettings(input);
+
+ if (!project) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to update project settings",
+ });
+ }
+
+ return project;
+ }),
+
delete: protectedProcedure
.input(
z.object({
diff --git a/apps/web/src/trpc/routers/translate.ts b/apps/web/src/trpc/routers/translate.ts
index 648ade78..e11253fe 100644
--- a/apps/web/src/trpc/routers/translate.ts
+++ b/apps/web/src/trpc/routers/translate.ts
@@ -9,12 +9,10 @@ import { waitUntil } from "@vercel/functions";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../init";
import { rateLimitMiddleware } from "../middlewares/ratelimits";
-import { isProjectMember } from "../permissions/project";
+import { isOrganizationMember } from "../permissions/organization";
export const translateRouter = createTRPCRouter({
pushTranslations: protectedProcedure
- .use(rateLimitMiddleware)
- // .use(isProjectMember)
.input(
z.object({
projectId: z.string(),
@@ -33,6 +31,8 @@ export const translateRouter = createTRPCRouter({
),
}),
)
+ .use(rateLimitMiddleware)
+ .use(isOrganizationMember)
.mutation(async ({ input, ctx }) => {
const project = await getProjectById({ id: input.projectId });
@@ -65,7 +65,7 @@ export const translateRouter = createTRPCRouter({
createTranslation({
projectId: input.projectId,
organizationId: project.organizationId,
- userId: ctx.user.id,
+ userId: ctx.type === "user" ? ctx.authenticatedId : undefined,
sourceFormat: input.sourceFormat,
translations: input.content.map((t, index) => ({
...t,
@@ -84,7 +84,6 @@ export const translateRouter = createTRPCRouter({
}),
getTranslationsBySlug: protectedProcedure
- // .use(isProjectMember)
.input(
z.object({
organizationId: z.string(),
@@ -93,6 +92,7 @@ export const translateRouter = createTRPCRouter({
limit: z.number().optional(),
}),
)
+ .use(isOrganizationMember)
.query(async ({ input }) => {
const data = await getTranslationsBySlug(input);
diff --git a/apps/web/src/trpc/routers/user.ts b/apps/web/src/trpc/routers/user.ts
index 613d616c..9eb1637e 100644
--- a/apps/web/src/trpc/routers/user.ts
+++ b/apps/web/src/trpc/routers/user.ts
@@ -10,7 +10,7 @@ import { rateLimitMiddleware } from "../middlewares/ratelimits";
export const userRouter = createTRPCRouter({
me: protectedProcedure.query(async ({ ctx }) => {
- return getUserById({ id: ctx.user.id });
+ return getUserById({ id: ctx.authenticatedId });
}),
update: protectedProcedure
@@ -22,18 +22,18 @@ export const userRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
- return updateUser({ id: ctx.user.id, ...input });
+ return updateUser({ id: ctx.authenticatedId, ...input });
}),
delete: protectedProcedure
.use(rateLimitMiddleware)
.mutation(async ({ ctx }) => {
- return deleteUser({ id: ctx.user.id });
+ return deleteUser({ id: ctx.authenticatedId });
}),
updateApiKey: protectedProcedure
.use(rateLimitMiddleware)
.mutation(async ({ ctx }) => {
- return updateUserApiKey(ctx.user.id);
+ return updateUserApiKey(ctx.authenticatedId);
}),
});
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 444f0608..39be7c32 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -63,3 +63,15 @@ have now extracted it into a standalone CLI tool.
Midday is a all in one tool for invoicing, Time tracking, File reconciliation,
Storage, Financial Overview & your own Assistant made for Freelancers
+
+
+## License
+
+This project is licensed under the **[AGPL-3.0](https://opensource.org/licenses/AGPL-3.0)** for non-commercial use.
+
+### Commercial Use
+
+For commercial use or deployments requiring a setup fee, please contact us
+for a commercial license at [engineer@languine.ai](mailto:engineer@languine.ai).
+
+By using this software, you agree to the terms of the license.
\ No newline at end of file
diff --git a/packages/cli/src/providers.ts b/packages/cli/src/providers.ts
index cab81d43..4951064d 100644
--- a/packages/cli/src/providers.ts
+++ b/packages/cli/src/providers.ts
@@ -26,7 +26,7 @@ export const providers: Record = {
hint: "recommended",
},
{ value: "gpt-4", label: "GPT-4" },
- { value: "gpt-4o", label: "GPT-4o", hint: "recommended" },
+ { value: "gpt-4o", label: "GPT-4o" },
{ value: "gpt-4o-mini", label: "GPT-4o mini" },
{ value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
],