diff --git a/apps/.DS_Store b/apps/.DS_Store new file mode 100644 index 00000000..85839b0e Binary files /dev/null and b/apps/.DS_Store differ diff --git a/apps/api/.env.example b/apps/api/.env.example index 611e3fc0..be2c70e9 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -3,6 +3,7 @@ BETTER_AUTH_SECRET=secret BETTER_AUTH_URL=http://localhost:3003 API_URL=http://localhost:3003 DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgress +NODE_ENV=development AI_GATEWAY_API_KEY= @@ -33,4 +34,8 @@ TWILIO_AUTH_TOKEN= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= SLACK_SIGNING_SECRET= -SLACK_BOT_TOKEN= \ No newline at end of file +SLACK_BOT_TOKEN= + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + diff --git a/apps/api/package.json b/apps/api/package.json index 9d900f05..fe54418c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -38,8 +38,12 @@ "@trpc/server": "^11.6.0", "ai": "5.0.87", "better-auth": "^1.3.24", + "cheerio": "^1.1.2", + "date-fns": "^4.1.0", "dotenv": "^17.2.1", "drizzle-orm": "0.44.6", + "google-auth-library": "^10.5.0", + "googleapis": "^166.0.0", "hono": "^4.9.8", "hono-rate-limiter": "^0.4.2", "octokit": "^5.0.3", diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 1d93e130..2d8991ab 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -32,6 +32,11 @@ export const auth = betterAuth({ return; } + // Bypass waitlist check in development + if (process.env.NODE_ENV === "development") { + return; + } + const [waitlistEntry] = await db .select() .from(waitlist) @@ -107,7 +112,7 @@ export const auth = betterAuth({ }, }, advanced: { - useSecureCookies: true, + useSecureCookies: process.env.NODE_ENV !== "development", crossSubDomainCookies: { enabled: true, domain: process.env.BETTER_AUTH_DOMAIN || "localhost", diff --git a/apps/api/src/lib/integration-logger.ts b/apps/api/src/lib/integration-logger.ts new file mode 100644 index 00000000..9e9184dc --- /dev/null +++ b/apps/api/src/lib/integration-logger.ts @@ -0,0 +1,45 @@ +import { db } from "@mimir/db/client"; +import { integrationLogs } from "@mimir/db/schema"; + +export async function logIntegrationError( + integrationId: string, + message: string, + details?: Record, + tokens?: { input?: number; output?: number }, +) { + try { + await db.insert(integrationLogs).values({ + integrationId, + level: "error", + message, + details: details || null, + inputTokens: tokens?.input || null, + outputTokens: tokens?.output || null, + }); + } catch (error) { + // Fallback to console if DB logging fails + console.error("Failed to log to integration_logs:", error); + console.error("Original error:", message, details); + } +} + +export async function logIntegrationInfo( + integrationId: string, + message: string, + details?: Record, + tokens?: { input?: number; output?: number }, +) { + try { + await db.insert(integrationLogs).values({ + integrationId, + level: "info", + message, + details: details || null, + inputTokens: tokens?.input || null, + outputTokens: tokens?.output || null, + }); + } catch (error) { + console.error("Failed to log to integration_logs:", error); + } +} + diff --git a/apps/api/src/rest/middleware/auth.ts b/apps/api/src/rest/middleware/auth.ts index 5251c56e..09dae8fe 100644 --- a/apps/api/src/rest/middleware/auth.ts +++ b/apps/api/src/rest/middleware/auth.ts @@ -3,7 +3,6 @@ import { getUserById } from "@mimir/db/queries/users"; import type { Session } from "better-auth"; import type { MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; -import type { Context } from "../types"; export const withAuth: MiddlewareHandler = async (c, next) => { const authSession = await auth.api.getSession({ diff --git a/apps/api/src/rest/routers/gmail-oauth.ts b/apps/api/src/rest/routers/gmail-oauth.ts new file mode 100644 index 00000000..57a942ec --- /dev/null +++ b/apps/api/src/rest/routers/gmail-oauth.ts @@ -0,0 +1,206 @@ +import { randomUUID } from "node:crypto"; +import { withAuth } from "@api/rest/middleware/auth"; +import type { Context } from "@api/rest/types"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { db } from "@mimir/db/client"; +import { createIntegrationUserLink } from "@mimir/db/queries/integrations"; +import { integrations } from "@mimir/db/schema"; +import { and, eq } from "drizzle-orm"; +import { google } from "googleapis"; + +const gmailOAuthRouter = new OpenAPIHono(); + +gmailOAuthRouter.use("*", withAuth); +if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) { + console.error( + "Missing required environment variables: GOOGLE_CLIENT_ID and/or GOOGLE_CLIENT_SECRET", + ); +} + +// Store for state validation (in production, use Redis) +const stateStore = new Map< + string, + { userId: string; teamId: string; expiresAt: number } +>(); + +gmailOAuthRouter.get("/authorize", async (c) => { + if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) { + return c.json( + { + error: + "Gmail OAuth is not configured on the server, Gmail integration is not available", + }, + 500, + ); + } + + const session = c.get("session"); + const teamId = c.get("teamId"); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + const state = randomUUID(); + stateStore.set(state, { + userId: session.userId, + teamId, + expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes + }); + + const oauth2Client = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + `${process.env.API_URL || "http://localhost:3003"}/api/integrations/gmail/callback`, + ); + + const authUrl = oauth2Client.generateAuthUrl({ + access_type: "offline", + scope: ["https://www.googleapis.com/auth/gmail.readonly"], + state, + prompt: "consent", + }); + + return c.redirect(authUrl); +}); + +gmailOAuthRouter.get("/callback", async (c) => { + const code = c.req.query("code"); + const state = c.req.query("state"); + const error = c.req.query("error"); + + if (error) { + return c.redirect( + `${process.env.APP_URL || "http://localhost:3000"}/dashboard/settings/integrations?error=${error}`, + ); + } + + if (!code || !state) { + return c.json({ error: "Missing code or state" }, 400); + } + + const stateData = stateStore.get(state); + if (!stateData || stateData.expiresAt < Date.now()) { + return c.json({ error: "Invalid or expired state" }, 400); + } + + stateStore.delete(state); + + const oauth2Client = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + `${process.env.API_URL || "http://localhost:3003"}/api/integrations/gmail/callback`, + ); + + try { + const { tokens } = await oauth2Client.getToken(code); + + if (!tokens.refresh_token) { + return c.json( + { + error: + "No refresh token received. Please revoke access and try again.", + }, + 400, + ); + } + + if (!tokens.access_token) { + return c.json( + { + error: "No access token received from Google", + }, + 400, + ); + } + + oauth2Client.setCredentials(tokens); + + const gmail = google.gmail({ version: "v1", auth: oauth2Client }); + const profile = await gmail.users.getProfile({ userId: "me" }); + + if (!profile.data.emailAddress) { + return c.json( + { + error: "Failed to fetch email address from Gmail", + }, + 400, + ); + } + + const googleEmail = profile.data.emailAddress; + const googleUserId = googleEmail; + + const [existingIntegration] = await db + .select() + .from(integrations) + .where( + and( + eq(integrations.type, "gmail"), + eq(integrations.teamId, stateData.teamId), + ), + ) + .limit(1); + + let integrationId: string; + + if (existingIntegration) { + await db + .update(integrations) + .set({ + config: { + refreshToken: tokens.refresh_token, + accessToken: tokens.access_token, + expiresAt: tokens.expiry_date, + mode: "auto", + allowDomains: [], + allowSenders: [], + denyDomains: [], + denySenders: [], + }, + updatedAt: new Date().toISOString(), + }) + .where(eq(integrations.id, existingIntegration.id)); + integrationId = existingIntegration.id; + } else { + const [newIntegration] = await db + .insert(integrations) + .values({ + teamId: stateData.teamId, + name: "Gmail", + type: "gmail", + config: { + refreshToken: tokens.refresh_token, + accessToken: tokens.access_token, + expiresAt: tokens.expiry_date, + mode: "auto", + allowDomains: [], + allowSenders: [], + denyDomains: [], + denySenders: [], + }, + }) + .returning(); + integrationId = newIntegration.id; + } + + await createIntegrationUserLink({ + userId: stateData.userId, + externalUserId: googleUserId, + externalUserName: googleEmail, + integrationId, + integrationType: "gmail", + }); + + return c.redirect( + `${process.env.APP_URL || "http://localhost:3000"}/dashboard/settings/integrations?success=gmail`, + ); + } catch (error) { + console.error("OAuth callback error:", error); + return c.redirect( + `${process.env.APP_URL || "http://localhost:3000"}/dashboard/settings/integrations?error=oauth_failed`, + ); + } +}); + +export default gmailOAuthRouter; diff --git a/apps/api/src/rest/routers/index.ts b/apps/api/src/rest/routers/index.ts index 6a240d98..0f55df87 100644 --- a/apps/api/src/rest/routers/index.ts +++ b/apps/api/src/rest/routers/index.ts @@ -4,6 +4,7 @@ import type { Context } from "../types"; import { attachmentsRouter } from "./attachments"; import { chatRouter } from "./chat"; import { githubRouter } from "./github"; +import gmailOAuthRouter from "./gmail-oauth"; import { importsRouter } from "./imports"; import { integrationsRouter } from "./integrations"; import { slackRouter } from "./slack"; @@ -11,9 +12,12 @@ import { transcriptionRouter } from "./transcription"; const routers = new OpenAPIHono(); +// Mount Gmail OAuth routes BEFORE auth middleware (they handle auth internally) +routers.route("/integrations/gmail", gmailOAuthRouter); + +// Apply auth middleware to all other routes routers.use(...protectedMiddleware); -// Mount protected routes routers.route("/chat", chatRouter); routers.route("/integrations", integrationsRouter); routers.route("/github", githubRouter); diff --git a/apps/api/src/trpc/routers/index.ts b/apps/api/src/trpc/routers/index.ts index 78018836..2d11c351 100644 --- a/apps/api/src/trpc/routers/index.ts +++ b/apps/api/src/trpc/routers/index.ts @@ -9,6 +9,7 @@ import { columnsRouter } from "./columns"; import { chatFeedbackRouter } from "./feedback"; import { githubRouter } from "./github"; import { importsRouter } from "./imports"; +import { intakeRouter } from "./intake"; import { integrationsRouter } from "./integrations"; import { labelsRouter } from "./labels"; import { notificationSettingsRouter } from "./notification-settings"; @@ -41,6 +42,7 @@ export const appRouter = router({ activities: activitiesRouter, github: githubRouter, imports: importsRouter, + intake: intakeRouter, notificationSettings: notificationSettingsRouter, resumeSettings: resumeSettingsRouter, widgets: widgetsRouter, diff --git a/apps/api/src/trpc/routers/intake.ts b/apps/api/src/trpc/routers/intake.ts new file mode 100644 index 00000000..618d8ba3 --- /dev/null +++ b/apps/api/src/trpc/routers/intake.ts @@ -0,0 +1,103 @@ +import { protectedProcedure, router } from "@api/trpc/init"; +import { db } from "@mimir/db/client"; +import { + getIntakeItemById, + getIntakes, + updateIntakeItemStatus, +} from "@mimir/db/queries/intake"; +import { createTask } from "@mimir/db/queries/tasks"; +import { intake, intakeStatusEnum } from "@mimir/db/schema"; +import { and, eq } from "drizzle-orm"; +import z from "zod"; + +export const intakeRouter = router({ + getIntakes: protectedProcedure + .input( + z.object({ + pageSize: z.number().min(1).max(100).optional(), + cursor: z.string().optional(), + status: z.enum(intakeStatusEnum.enumValues).optional(), + }), + ) + .query(async ({ ctx, input }) => { + return await getIntakes({ + teamId: ctx.user.teamId!, + status: input.status, + pageSize: input.pageSize, + cursor: input.cursor, + }); + }), + + getById: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const item = await getIntakeItemById({ + id: input.id, + teamId: ctx.user.teamId!, + }); + + if (!item) { + throw new Error("Item not found"); + } + return item; + }), + + updateStatus: protectedProcedure + .input( + z.object({ + id: z.string(), + status: z.enum(intakeStatusEnum.enumValues), + }), + ) + .mutation(async ({ ctx, input }) => { + return await updateIntakeItemStatus({ + id: input.id, + teamId: ctx.user.teamId!, + status: input.status, + }); + }), + + acceptAndCreateTask: protectedProcedure + .input( + z.object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + columnId: z.string(), + assigneeId: z.string().optional(), + priority: z.enum(["low", "medium", "high", "urgent"]).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { id, ...taskData } = input; + + // Fetch the intake item + const item = await getIntakeItemById({ + id, + teamId: ctx.user.teamId!, + }); + + if (!item) { + throw new Error("Intake item not found"); + } + + // Create the task + const task = await createTask({ + ...taskData, + userId: ctx.user.id, + teamId: ctx.user.teamId!, + }); + + // Update intake item + await db + .update(intake) + .set({ + status: "accepted", + taskId: task.id, + updatedAt: new Date().toISOString(), + }) + .where(and(eq(intake.id, id), eq(intake.teamId, ctx.user.teamId!))); + + return { task, intake: item }; + }), +}); diff --git a/apps/dashboard/public/cover4.png b/apps/dashboard/public/cover4.png index 2d479c01..80c7c024 100644 Binary files a/apps/dashboard/public/cover4.png and b/apps/dashboard/public/cover4.png differ diff --git a/apps/dashboard/src/app/dashboard/(navigation)/intake/page.tsx b/apps/dashboard/src/app/dashboard/(navigation)/intake/page.tsx new file mode 100644 index 00000000..93febc0c --- /dev/null +++ b/apps/dashboard/src/app/dashboard/(navigation)/intake/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import IntakeActionForm, { + type TaskFormValues, +} from "@/components/intake-action-form"; +import IntakeInboxList from "@/components/intake-inbox-list"; +import IntakeItemDetail from "@/components/intake-item-detail"; +import { trpc } from "@/utils/trpc"; + +export default function IntakePage() { + const queryClient = useQueryClient(); + const [selectedItemId, setSelectedItemId] = useState(null); + + const { data: intakeData } = useQuery( + trpc.intake.getIntakes.queryOptions({ + pageSize: 50, + status: "pending", + }), + ); + + const intakeItems = intakeData?.data; + + const { data: columnsData } = useQuery( + trpc.columns.get.queryOptions({ + type: ["to_do", "in_progress", "backlog"], + }), + ); + + const { data: membersData } = useQuery(trpc.teams.getMembers.queryOptions()); + + const columns = columnsData?.data; + const members = membersData; + + const selectedItem = intakeItems?.find((item) => item.id === selectedItemId); + + const { mutateAsync: createTask, isPending: isCreating } = useMutation( + trpc.intake.acceptAndCreateTask.mutationOptions({ + onSuccess: () => { + toast.success("Task created successfully!"); + queryClient.invalidateQueries({ + queryKey: [["intake", "getIntakes"]], + }); + // Move to next item + const index = + intakeItems?.findIndex((i) => i.id === selectedItemId) ?? -1; + if (index !== -1 && intakeItems && index < intakeItems.length - 1) { + setSelectedItemId(intakeItems[index + 1]!.id); + } else { + setSelectedItemId(null); + } + }, + onError: (error) => { + toast.error(error.message || "Failed to create task"); + }, + }), + ); + + const { mutateAsync: rejectItem, isPending: isRejecting } = useMutation( + trpc.intake.updateStatus.mutationOptions({ + onSuccess: () => { + toast.success("Item rejected"); + queryClient.invalidateQueries({ + queryKey: [["intake", "getIntakes"]], + }); + // Move to next item + const index = + intakeItems?.findIndex((i) => i.id === selectedItemId) ?? -1; + if (index !== -1 && intakeItems && index < intakeItems.length - 1) { + setSelectedItemId(intakeItems[index + 1]!.id); + } else { + setSelectedItemId(null); + } + }, + onError: (error) => { + toast.error(error.message || "Failed to reject item"); + }, + }), + ); + + const handleCreateTask = async (values: TaskFormValues) => { + if (!selectedItemId) return; + await createTask({ + id: selectedItemId, + ...values, + }); + }; + + const handleReject = async () => { + if (!selectedItemId) return; + await rejectItem({ id: selectedItemId, status: "rejected" }); + }; + + return ( +
+ + + {/* Right: Context (Top) + Actions (Bottom) */} +
+ {/* Top Section: Email Context */} +
+ +
+ + {/* Bottom Section: Action Form */} +
+ +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/(navigation)/settings/integrations/gmail/gmail-config-form.tsx b/apps/dashboard/src/app/dashboard/(navigation)/settings/integrations/gmail/gmail-config-form.tsx new file mode 100644 index 00000000..512ab40d --- /dev/null +++ b/apps/dashboard/src/app/dashboard/(navigation)/settings/integrations/gmail/gmail-config-form.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@mimir/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@mimir/ui/form"; +import { Input } from "@mimir/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@mimir/ui/select"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { trpc } from "@/utils/trpc"; + +const gmailConfigSchema = z.object({ + mode: z.enum(["auto", "strict_allow", "strict_deny"]), + allowDomains: z.string().optional(), + allowSenders: z.string().optional(), + denyDomains: z.string().optional(), + denySenders: z.string().optional(), +}); + +type GmailConfigFormValues = z.infer; + +interface GmailConfigFormProps { + integrationId: string; + defaultConfig: { + mode?: string; + allowDomains?: string[]; + allowSenders?: string[]; + denyDomains?: string[]; + denySenders?: string[]; + refreshToken?: string; + accessToken?: string; + expiresAt?: number; + lastSyncedAt?: string; + }; +} + +export function GmailConfigForm({ + integrationId, + defaultConfig, +}: GmailConfigFormProps) { + const queryClient = useQueryClient(); + + const { mutateAsync: updateIntegration } = useMutation( + trpc.integrations.update.mutationOptions({ + onSuccess: () => { + toast.success("Settings updated successfully"); + queryClient.invalidateQueries({ + queryKey: [ + ["integrations", "getByType"], + { input: { type: "gmail" } }, + ], + }); + }, + onError: (error: any) => { + toast.error(error.message || "Failed to update settings"); + }, + }), + ); + + const form = useForm({ + resolver: zodResolver(gmailConfigSchema), + defaultValues: { + mode: (defaultConfig.mode as any) || "auto", + allowDomains: (defaultConfig.allowDomains || []).join(", "), + allowSenders: (defaultConfig.allowSenders || []).join(", "), + denyDomains: (defaultConfig.denyDomains || []).join(", "), + denySenders: (defaultConfig.denySenders || []).join(", "), + }, + }); + + const onSubmit = async (data: GmailConfigFormValues) => { + // Helper to split, normalize, and filter empty values + const splitAndFilter = (str?: string) => { + if (!str) return []; + return str + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length > 0); + }; + + await updateIntegration({ + id: integrationId, + config: { + ...defaultConfig, + mode: data.mode, + allowDomains: splitAndFilter(data.allowDomains), + allowSenders: splitAndFilter(data.allowSenders), + denyDomains: splitAndFilter(data.denyDomains), + denySenders: splitAndFilter(data.denySenders), + } as any, + }); + }; + + const handleReconnect = () => { + // Redirect to OAuth flow + const apiUrl = + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3003"; + window.location.href = `${apiUrl}/api/integrations/gmail/authorize`; + }; + + return ( +
+
+
+

Connection Status

+

Connected to Gmail

+
+ +
+ +
+ + ( + + Filtering Mode + + + Auto uses AI to detect actionable items. Strict modes rely + only on your lists. + + + + )} + /> + + {(form.watch("mode") === "strict_allow" || + form.watch("mode") === "auto") && ( + <> + ( + + Allowed Domains + + + + + Comma-separated list of domains to always process + + + + )} + /> + + ( + + Allowed Senders + + + + + Comma-separated list of email addresses to always process + + + + )} + /> + + )} + + {(form.watch("mode") === "strict_deny" || + form.watch("mode") === "auto") && ( + <> + ( + + Denied Domains + + + + + Comma-separated list of domains to always ignore + + + + )} + /> + + ( + + Denied Senders + + + + + Comma-separated list of email addresses to always ignore + + + + )} + /> + + )} + +
+ +
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/(navigation)/settings/integrations/gmail/page.tsx b/apps/dashboard/src/app/dashboard/(navigation)/settings/integrations/gmail/page.tsx new file mode 100644 index 00000000..0ba56ef4 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/(navigation)/settings/integrations/gmail/page.tsx @@ -0,0 +1,48 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@mimir/ui/card"; +import { notFound } from "next/navigation"; +import { queryClient, trpc } from "@/utils/trpc"; +import { LogsList } from "../logs-list"; +import { GmailConfigForm } from "./gmail-config-form"; + +export const revalidate = 0; + +export default async function Page() { + const integrationInfo = await queryClient.fetchQuery( + trpc.integrations.getByType.queryOptions({ + type: "gmail", + }), + ); + + const integration = integrationInfo.installedIntegration[0]; + + if (!integration) { + return notFound(); + } + + const id = integration.id; + + return ( +
+ + + Gmail Configuration + + Configure how Mimir processes your incoming emails. + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/(navigation)/settings/integrations/integrations-list.tsx b/apps/dashboard/src/app/dashboard/(navigation)/settings/integrations/integrations-list.tsx index 795471ac..c9433f20 100644 --- a/apps/dashboard/src/app/dashboard/(navigation)/settings/integrations/integrations-list.tsx +++ b/apps/dashboard/src/app/dashboard/(navigation)/settings/integrations/integrations-list.tsx @@ -64,7 +64,7 @@ export const IntegrationsList = () => { setParams({ installType: integration.type }) } > - Install + {integration.type === "gmail" ? "Connect" : "Install"} )} diff --git a/apps/dashboard/src/app/dashboard/(navigation)/settings/profile/profile-form.tsx b/apps/dashboard/src/app/dashboard/(navigation)/settings/profile/profile-form.tsx index 62517668..7db473db 100644 --- a/apps/dashboard/src/app/dashboard/(navigation)/settings/profile/profile-form.tsx +++ b/apps/dashboard/src/app/dashboard/(navigation)/settings/profile/profile-form.tsx @@ -21,6 +21,7 @@ import { useMutation } from "@tanstack/react-query"; import { Loader2Icon } from "lucide-react"; import { toast } from "sonner"; import z from "zod"; +import { ThemeToggle } from "@/components/theme-toggle"; import { useZodForm } from "@/hooks/use-zod-form"; import { queryClient, trpc } from "@/utils/trpc"; @@ -99,6 +100,11 @@ export const ProfileForm = ({ )} /> +
+ Theme + +
+
+
+ +
- OR -
+ + ( + + Refresh Token (Manual) + + + + + If you already have a refresh token, paste it here instead. + + + + )} + /> + + {(error || isValid) && ( + + {error ? : } + + {error + ? "Validation failed" + : isValid + ? "Configuration is valid" + : ""} + + {error && {error}} + + )} + +
+ +
+ + + ); +}; diff --git a/apps/dashboard/src/components/intake-action-form.tsx b/apps/dashboard/src/components/intake-action-form.tsx new file mode 100644 index 00000000..70104066 --- /dev/null +++ b/apps/dashboard/src/components/intake-action-form.tsx @@ -0,0 +1,263 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import type { RouterOutputs } from "@mimir/api/trpc"; +import { Check, Trash2 } from "lucide-react"; +import type React from "react"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; + +const taskFormSchema = z.object({ + title: z.string().min(1, "Title is required"), + description: z.string().optional(), + columnId: z.string().min(1, "Column is required"), + assigneeId: z.string().optional(), + priority: z.enum(["low", "medium", "high", "urgent"]).optional(), +}); + +export type TaskFormValues = z.infer; + +interface IntakeActionFormProps { + selectedItem: + | RouterOutputs["intake"]["getIntakes"]["data"][number] + | undefined; + columns: + | { + id: string; + name: string; + type: string; + }[] + | undefined; + members: + | { + id: string; + name: string; + }[] + | undefined; + onSubmit: (values: TaskFormValues) => Promise; + onReject: () => Promise; + isCreating: boolean; + isRejecting: boolean; +} + +const IntakeActionForm: React.FC = ({ + selectedItem, + columns, + members, + onSubmit, + onReject, + isCreating, + isRejecting, +}) => { + const form = useForm({ + resolver: zodResolver(taskFormSchema), + defaultValues: { + title: "", + description: "", + priority: "medium", + }, + }); + + // Auto-populate form when an item is selected + useEffect(() => { + if (selectedItem && columns) { + const defaultColumn = columns.find((c) => c.type === "to_do"); + form.reset({ + title: + selectedItem.aiAnalysis?.suggestedTitle || + selectedItem.metadata?.subject || + "", + description: + selectedItem.aiAnalysis?.suggestedDescription || + selectedItem.content.substring(0, 500), + columnId: defaultColumn?.id, + priority: selectedItem.aiAnalysis?.suggestedPriority || "medium", + }); + } + }, [selectedItem?.id, columns, form]); + + if (!selectedItem) { + return ( +
+

No item selected

+

Select an item to create a task

+
+ ); + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const values = form.getValues(); + await onSubmit(values); + }; + + return ( +
+
+
+

+ Create Task +

+ +
+
+ + + {form.formState.errors.title && ( +

+ {form.formState.errors.title.message} +

+ )} +
+ +
+ +