intial files
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@code/auth",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./native": "./src/native/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./web": "./src/web/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/expo": "catalog:",
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@code/ui": "workspace:*",
|
||||
"@convex-dev/better-auth": "catalog:",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"expo-constants": "~57.0.2",
|
||||
"expo-secure-store": "~57.0.0",
|
||||
"heroui-native": "^1.0.5",
|
||||
"react": "^19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"sonner": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@types/react": "~19.2.17",
|
||||
"vite": "^7.3.6",
|
||||
"typescript": "^6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expoClient } from "@better-auth/expo/client";
|
||||
import { env } from "@code/env/native";
|
||||
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import Constants from "expo-constants";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const scheme = Constants.expoConfig?.scheme;
|
||||
|
||||
if (typeof scheme !== "string") {
|
||||
throw new Error("Expo auth requires a string app scheme in app.json");
|
||||
}
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: env.EXPO_PUBLIC_CONVEX_SITE_URL,
|
||||
plugins: [
|
||||
convexClient(),
|
||||
Platform.OS === "web"
|
||||
? crossDomainClient()
|
||||
: expoClient({ scheme, storage: SecureStore, storagePrefix: scheme }),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { env } from "@code/env/native";
|
||||
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
|
||||
import { ConvexReactClient } from "convex/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { authClient } from "./auth-client";
|
||||
|
||||
const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, {
|
||||
unsavedChangesWarning: false,
|
||||
});
|
||||
|
||||
export const NativeAuthProvider = ({ children }: { children: ReactNode }) => (
|
||||
<ConvexBetterAuthProvider authClient={authClient} client={convex}>
|
||||
{children}
|
||||
</ConvexBetterAuthProvider>
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useToast } from "heroui-native";
|
||||
|
||||
import { getErrorMessage, signInSchema, signUpSchema } from "../shared/forms";
|
||||
import { authClient } from "./auth-client";
|
||||
|
||||
type AuthFormOptions = {
|
||||
readonly onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const useNativeSignInForm = ({ onSuccess }: AuthFormOptions = {}) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
return useForm({
|
||||
defaultValues: { email: "", password: "" },
|
||||
onSubmit: async ({ formApi, value }) => {
|
||||
await authClient.signIn.email(
|
||||
{ email: value.email.trim(), password: value.password },
|
||||
{
|
||||
onError: ({ error }) => {
|
||||
toast.show({ label: error.message || "Failed to sign in", variant: "danger" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
formApi.reset();
|
||||
toast.show({ label: "Signed in successfully", variant: "success" });
|
||||
onSuccess?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: { onSubmit: signInSchema },
|
||||
});
|
||||
};
|
||||
|
||||
export const useNativeSignUpForm = ({ onSuccess }: AuthFormOptions = {}) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
return useForm({
|
||||
defaultValues: { confirmPassword: "", email: "", name: "", password: "" },
|
||||
onSubmit: async ({ formApi, value }) => {
|
||||
await authClient.signUp.email(
|
||||
{ email: value.email.trim(), name: value.name.trim(), password: value.password },
|
||||
{
|
||||
onError: ({ error }) => {
|
||||
toast.show({
|
||||
label: error.message || "Failed to create account",
|
||||
variant: "danger",
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
formApi.reset();
|
||||
toast.show({ label: "Account created successfully", variant: "success" });
|
||||
onSuccess?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: { onSubmit: signUpSchema },
|
||||
});
|
||||
};
|
||||
|
||||
export { getErrorMessage };
|
||||
@@ -0,0 +1,4 @@
|
||||
export { authClient } from "./auth-client";
|
||||
export { NativeAuthProvider } from "./auth-provider";
|
||||
export { NativeLoginForm } from "./login-form";
|
||||
export { NativeSignupForm } from "./signup-form";
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Button,
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
Spinner,
|
||||
Surface,
|
||||
TextField,
|
||||
useThemeColor,
|
||||
} from "heroui-native";
|
||||
import { useRef } from "react";
|
||||
import { Text, type TextInput, View } from "react-native";
|
||||
|
||||
import { getErrorMessage, useNativeSignInForm } from "./hooks";
|
||||
|
||||
type NativeLoginFormProps = {
|
||||
readonly onNavigateSignUp: () => void;
|
||||
readonly onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const NativeLoginForm = ({
|
||||
onNavigateSignUp,
|
||||
onSuccess,
|
||||
}: NativeLoginFormProps) => {
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const form = useNativeSignInForm({ onSuccess });
|
||||
const foregroundColor = useThemeColor("foreground");
|
||||
const mutedColor = useThemeColor("muted");
|
||||
|
||||
return (
|
||||
<Surface className="rounded-xl p-5" variant="secondary">
|
||||
<Text style={{ color: foregroundColor, fontSize: 24, fontWeight: "600", marginBottom: 4 }}>
|
||||
Welcome back
|
||||
</Text>
|
||||
<Text style={{ color: mutedColor, fontSize: 14, marginBottom: 20 }}>
|
||||
Sign in with your email and password.
|
||||
</Text>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSubmitting: state.isSubmitting,
|
||||
submitError: getErrorMessage(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, submitError }) => (
|
||||
<View style={{ gap: 16 }}>
|
||||
<FieldError className="mb-1" isInvalid={!!submitError}>
|
||||
{submitError}
|
||||
</FieldError>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
blurOnSubmit={false}
|
||||
keyboardType="email-address"
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
onSubmitEditing={() => passwordInputRef.current?.focus()}
|
||||
placeholder="you@example.com"
|
||||
returnKeyType="next"
|
||||
textContentType="emailAddress"
|
||||
value={field.state.value}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
autoComplete="password"
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
onSubmitEditing={() => void form.handleSubmit()}
|
||||
ref={passwordInputRef}
|
||||
returnKeyType="go"
|
||||
secureTextEntry
|
||||
textContentType="password"
|
||||
value={field.state.value}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button
|
||||
className="mt-1"
|
||||
isDisabled={isSubmitting}
|
||||
onPress={() => void form.handleSubmit()}
|
||||
>
|
||||
{isSubmitting ? <Spinner color="default" size="sm" /> : <Button.Label>Sign in</Button.Label>}
|
||||
</Button>
|
||||
|
||||
<Button onPress={onNavigateSignUp} variant="ghost">
|
||||
<Button.Label>Need an account? Sign up</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
Button,
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
Spinner,
|
||||
Surface,
|
||||
TextField,
|
||||
useThemeColor,
|
||||
} from "heroui-native";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { getErrorMessage, useNativeSignUpForm } from "./hooks";
|
||||
|
||||
type NativeSignupFormProps = {
|
||||
readonly onNavigateSignIn: () => void;
|
||||
readonly onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const NativeSignupForm = ({
|
||||
onNavigateSignIn,
|
||||
onSuccess,
|
||||
}: NativeSignupFormProps) => {
|
||||
const form = useNativeSignUpForm({ onSuccess });
|
||||
const foregroundColor = useThemeColor("foreground");
|
||||
const mutedColor = useThemeColor("muted");
|
||||
|
||||
return (
|
||||
<Surface className="rounded-xl p-5" variant="secondary">
|
||||
<Text style={{ color: foregroundColor, fontSize: 24, fontWeight: "600", marginBottom: 4 }}>
|
||||
Create an account
|
||||
</Text>
|
||||
<Text style={{ color: mutedColor, fontSize: 14, marginBottom: 20 }}>
|
||||
Use your name, email, and password.
|
||||
</Text>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSubmitting: state.isSubmitting,
|
||||
submitError: getErrorMessage(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, submitError }) => (
|
||||
<View style={{ gap: 16 }}>
|
||||
<FieldError className="mb-1" isInvalid={!!submitError}>
|
||||
{submitError}
|
||||
</FieldError>
|
||||
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Full name</Label>
|
||||
<Input
|
||||
autoCapitalize="words"
|
||||
autoComplete="name"
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="Jane Doe"
|
||||
textContentType="name"
|
||||
value={field.state.value}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="you@example.com"
|
||||
textContentType="emailAddress"
|
||||
value={field.state.value}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
secureTextEntry
|
||||
textContentType="newPassword"
|
||||
value={field.state.value}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="confirmPassword">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Confirm password</Label>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
onSubmitEditing={() => void form.handleSubmit()}
|
||||
returnKeyType="go"
|
||||
secureTextEntry
|
||||
textContentType="newPassword"
|
||||
value={field.state.value}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button
|
||||
className="mt-1"
|
||||
isDisabled={isSubmitting}
|
||||
onPress={() => void form.handleSubmit()}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Spinner color="default" size="sm" />
|
||||
) : (
|
||||
<Button.Label>Create account</Button.Label>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button onPress={onNavigateSignIn} variant="ghost">
|
||||
<Button.Label>Already have an account? Sign in</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { authComponent, createAuth } from "@code/backend/convex/auth";
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const signInSchema = z.object({
|
||||
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
|
||||
});
|
||||
|
||||
export const signUpSchema = z
|
||||
.object({
|
||||
confirmPassword: z.string().min(1, "Confirm your password"),
|
||||
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
|
||||
name: z.string().trim().min(2, "Name must be at least 2 characters"),
|
||||
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
|
||||
})
|
||||
.refine(({ confirmPassword, password }) => confirmPassword === password, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export const getErrorMessage = (error: unknown): string | null => {
|
||||
if (!error) return null;
|
||||
if (typeof error === "string") return error;
|
||||
|
||||
if (Array.isArray(error)) {
|
||||
for (const issue of error) {
|
||||
const message = getErrorMessage(issue);
|
||||
if (message) return message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
const { message } = error as { message?: unknown };
|
||||
return typeof message === "string" ? message : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { env } from "@code/env/web";
|
||||
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: env.VITE_CONVEX_SITE_URL,
|
||||
plugins: [convexClient(), crossDomainClient()],
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { env } from "@code/env/web";
|
||||
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
|
||||
import { ConvexReactClient } from "convex/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { authClient } from "./auth-client";
|
||||
|
||||
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
|
||||
|
||||
export const WebAuthProvider = ({ children }: { children: ReactNode }) => (
|
||||
<ConvexBetterAuthProvider authClient={authClient} client={convex}>
|
||||
{children}
|
||||
</ConvexBetterAuthProvider>
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { signInSchema, signUpSchema } from "../shared/forms";
|
||||
import { authClient } from "./auth-client";
|
||||
|
||||
type AuthFormOptions = {
|
||||
readonly onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const useSignInForm = ({ onSuccess }: AuthFormOptions = {}) =>
|
||||
useForm({
|
||||
defaultValues: { email: "", password: "" },
|
||||
onSubmit: async ({ formApi, value }) => {
|
||||
await authClient.signIn.email(
|
||||
{ email: value.email.trim(), password: value.password },
|
||||
{
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.message || error.statusText);
|
||||
},
|
||||
onSuccess: () => {
|
||||
formApi.reset();
|
||||
toast.success("Signed in successfully");
|
||||
onSuccess?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: { onSubmit: signInSchema },
|
||||
});
|
||||
|
||||
export const useSignUpForm = ({ onSuccess }: AuthFormOptions = {}) =>
|
||||
useForm({
|
||||
defaultValues: { confirmPassword: "", email: "", name: "", password: "" },
|
||||
onSubmit: async ({ formApi, value }) => {
|
||||
await authClient.signUp.email(
|
||||
{ email: value.email.trim(), name: value.name.trim(), password: value.password },
|
||||
{
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.message || error.statusText);
|
||||
},
|
||||
onSuccess: () => {
|
||||
formApi.reset();
|
||||
toast.success("Account created successfully");
|
||||
onSuccess?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: { onSubmit: signUpSchema },
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export { authClient } from "./auth-client";
|
||||
export { WebAuthProvider } from "./auth-provider";
|
||||
export { LoginForm } from "./login-form";
|
||||
export { SignupForm } from "./signup-form";
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@code/ui/components/card";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@code/ui/components/field";
|
||||
import { Input } from "@code/ui/components/input";
|
||||
import { cn } from "@code/ui/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { getErrorMessage } from "../shared/forms";
|
||||
import { useSignInForm } from "./hooks";
|
||||
|
||||
type LoginFormProps = ComponentProps<"div"> & {
|
||||
readonly onSuccess?: () => void;
|
||||
readonly signUpHref?: string;
|
||||
};
|
||||
|
||||
export const LoginForm = ({
|
||||
className,
|
||||
onSuccess,
|
||||
signUpHref = "/signup",
|
||||
...props
|
||||
}: LoginFormProps) => {
|
||||
const form = useSignInForm({ onSuccess });
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome back</CardTitle>
|
||||
<CardDescription>Enter your email and password to continue.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<Field data-invalid={!field.state.meta.isValid}>
|
||||
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
placeholder="you@example.com"
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<Field data-invalid={!field.state.meta.isValid}>
|
||||
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
|
||||
<Input
|
||||
autoComplete="current-password"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
canSubmit: state.canSubmit,
|
||||
isSubmitting: state.isSubmitting,
|
||||
submitError: getErrorMessage(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ canSubmit, isSubmitting, submitError }) => (
|
||||
<Field>
|
||||
<FieldError>{submitError}</FieldError>
|
||||
<Button disabled={!canSubmit || isSubmitting} type="submit">
|
||||
{isSubmitting ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
<FieldDescription className="text-center">
|
||||
Don't have an account? <a href={signUpHref}>Sign up</a>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@code/ui/components/card";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@code/ui/components/field";
|
||||
import { Input } from "@code/ui/components/input";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { getErrorMessage } from "../shared/forms";
|
||||
import { useSignUpForm } from "./hooks";
|
||||
|
||||
type SignupFormProps = ComponentProps<typeof Card> & {
|
||||
readonly onSuccess?: () => void;
|
||||
readonly signInHref?: string;
|
||||
};
|
||||
|
||||
export const SignupForm = ({
|
||||
onSuccess,
|
||||
signInHref = "/login",
|
||||
...props
|
||||
}: SignupFormProps) => {
|
||||
const form = useSignUpForm({ onSuccess });
|
||||
|
||||
return (
|
||||
<Card {...props}>
|
||||
<CardHeader>
|
||||
<CardTitle>Create an account</CardTitle>
|
||||
<CardDescription>Use your name, email, and password to get started.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<Field data-invalid={!field.state.meta.isValid}>
|
||||
<FieldLabel htmlFor={field.name}>Full name</FieldLabel>
|
||||
<Input
|
||||
autoComplete="name"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
placeholder="Jane Doe"
|
||||
value={field.state.value}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<Field data-invalid={!field.state.meta.isValid}>
|
||||
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
placeholder="you@example.com"
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<Field data-invalid={!field.state.meta.isValid}>
|
||||
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
/>
|
||||
<FieldDescription>Use at least 8 characters.</FieldDescription>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="confirmPassword">
|
||||
{(field) => (
|
||||
<Field data-invalid={!field.state.meta.isValid}>
|
||||
<FieldLabel htmlFor={field.name}>Confirm password</FieldLabel>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
canSubmit: state.canSubmit,
|
||||
isSubmitting: state.isSubmitting,
|
||||
submitError: getErrorMessage(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ canSubmit, isSubmitting, submitError }) => (
|
||||
<Field>
|
||||
<FieldError>{submitError}</FieldError>
|
||||
<Button disabled={!canSubmit || isSubmitting} type="submit">
|
||||
{isSubmitting ? "Creating account…" : "Create account"}
|
||||
</Button>
|
||||
<FieldDescription className="text-center">
|
||||
Already have an account? <a href={signInHref}>Sign in</a>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@code/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["node", "vite/client"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const ARTIFACT_PATHS = [
|
||||
"project.md",
|
||||
"business.md",
|
||||
"design.md",
|
||||
"agent.md",
|
||||
"work.md",
|
||||
"steps.md",
|
||||
"artifacts.md",
|
||||
"signals.md",
|
||||
"agent-manager.md",
|
||||
"context.md",
|
||||
"card.md",
|
||||
] as const;
|
||||
|
||||
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
|
||||
|
||||
export const artifactPath = v.union(
|
||||
v.literal("project.md"),
|
||||
v.literal("business.md"),
|
||||
v.literal("design.md"),
|
||||
v.literal("agent.md"),
|
||||
v.literal("work.md"),
|
||||
v.literal("steps.md"),
|
||||
v.literal("artifacts.md"),
|
||||
v.literal("signals.md"),
|
||||
v.literal("agent-manager.md"),
|
||||
v.literal("context.md"),
|
||||
v.literal("card.md")
|
||||
);
|
||||
|
||||
type ProjectSeed = {
|
||||
readonly defaultBranch: string;
|
||||
readonly description?: string;
|
||||
readonly name: string;
|
||||
readonly repoName: string;
|
||||
readonly repoOwner: string;
|
||||
readonly repoUrl: string;
|
||||
};
|
||||
|
||||
export const createInitialArtifacts = (
|
||||
project: ProjectSeed
|
||||
): ReadonlyArray<{ path: ArtifactPath; content: string }> => {
|
||||
const repository = `${project.repoOwner}/${project.repoName}`;
|
||||
const purpose = project.description ?? `Maintain and improve ${repository}.`;
|
||||
|
||||
return [
|
||||
{
|
||||
path: "project.md",
|
||||
content: `# ${project.name}\n\nRepository: [${repository}](${project.repoUrl})\n\n## Project knowledge\n\n- [Business](business.md)\n- [Design](design.md)\n- [Agent](agent.md)\n\n## Delivery\n\n- [Work](work.md)\n- [Steps](steps.md)\n- [Artifacts](artifacts.md)\n`,
|
||||
},
|
||||
{
|
||||
path: "business.md",
|
||||
content: `# Business\n\n## Purpose\n\n${purpose}\n\n## Source of truth\n\nThe connected GitHub repository and its project issues define the current product work.\n`,
|
||||
},
|
||||
{
|
||||
path: "design.md",
|
||||
content: `# Design\n\n## Repository boundary\n\nWork targets \`${repository}\` on \`${project.defaultBranch}\`. Preserve its established architecture and conventions before introducing new patterns.\n\n## Decision record\n\nRecord issue-specific architecture decisions here when they affect later work.\n`,
|
||||
},
|
||||
{
|
||||
path: "agent.md",
|
||||
content: `# Agent\n\n## Role\n\nOwn one project issue at a time. Read the project artifacts before editing code, ask a focused question when blocked, and support completion claims with command output.\n\n## Guardrails\n\n- Keep issue work isolated in its AgentOS workspace.\n- Update work.md, steps.md, artifacts.md, and context.md as facts change.\n- Never mark work complete without a relevant runtime check.\n`,
|
||||
},
|
||||
{
|
||||
path: "work.md",
|
||||
content: "# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
|
||||
},
|
||||
{
|
||||
path: "steps.md",
|
||||
content: "# Steps\n\n1. Read the issue and project artifacts.\n2. Inspect the repository context.\n3. Ask for missing decisions only when work cannot continue safely.\n4. Implement the smallest complete change.\n5. Run the relevant command or scenario.\n6. Publish changed artifacts and the final signal.\n",
|
||||
},
|
||||
{
|
||||
path: "artifacts.md",
|
||||
content: "# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
|
||||
},
|
||||
{
|
||||
path: "signals.md",
|
||||
content: "# Signals\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. Signals are durable project events and drive the web status.\n",
|
||||
},
|
||||
{
|
||||
path: "agent-manager.md",
|
||||
content: "# Agent manager\n\n## State machine\n\n`open -> queued -> working -> completed`\n\nA blocked run moves from `working` to `needs-input`; a resumed run returns to `queued`. An unrecoverable run moves to `failed`.\n\nEach issue owns one Flue agent identity and one AgentOS actor key, so conversation and workspace state remain isolated.\n",
|
||||
},
|
||||
{
|
||||
path: "context.md",
|
||||
content: `# Context\n\n- Provider: GitHub\n- Repository: ${repository}\n- URL: ${project.repoUrl}\n- Default branch: ${project.defaultBranch}\n\nIssue-specific facts belong below this repository context.\n`,
|
||||
},
|
||||
{
|
||||
path: "card.md",
|
||||
content: "# Project card\n\nThe web project card is the interface between UI and packages. It reads `projects.list`, `projectArtifacts.list`, and `projectIssues.list`; it writes through `projects.connectGitHub`, `projectIssues.create`, and `projectIssues.begin`. The issue-scoped Flue agent reads `agentWorkspace.get`, publishes with `agentWorkspace.updateArtifact`, and reports state with `agentWorkspace.setStatus`. Filesystem and shell operations are executed by the AgentOS sandbox adapter through durable daemon commands.\n",
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
|
||||
export type AuthContext = {
|
||||
readonly auth: {
|
||||
readonly getUserIdentity: () => Promise<{
|
||||
readonly tokenIdentifier: string;
|
||||
} | null>;
|
||||
};
|
||||
};
|
||||
|
||||
export const requireOwnerId = async (ctx: AuthContext): Promise<string> => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
throw new ConvexError("Authentication required");
|
||||
}
|
||||
return identity.tokenIdentifier;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
|
||||
.take(25);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
|
||||
const requireProjectOwner = async (
|
||||
ctx: Parameters<Parameters<typeof mutation>[0]["handler"]>[0],
|
||||
projectId: Parameters<typeof ctx.db.get<"projects">>[1],
|
||||
ownerId: string
|
||||
) => {
|
||||
const project = await ctx.db.get("projects", projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
return project;
|
||||
};
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
await requireProjectOwner(ctx, args.projectId, ownerId);
|
||||
const title = args.title.trim();
|
||||
const body = args.body.trim();
|
||||
if (title.length < 3 || title.length > 160) {
|
||||
throw new ConvexError("Issue title must be between 3 and 160 characters");
|
||||
}
|
||||
if (body.length < 10 || body.length > 10_000) {
|
||||
throw new ConvexError("Issue description must be between 10 and 10000 characters");
|
||||
}
|
||||
const latest = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.first();
|
||||
const timestamp = Date.now();
|
||||
const number = (latest?.number ?? 0) + 1;
|
||||
const issueId = await ctx.db.insert("projectIssues", {
|
||||
projectId: args.projectId,
|
||||
number,
|
||||
title,
|
||||
body,
|
||||
status: "open",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const workArtifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", args.projectId).eq("path", "work.md")
|
||||
)
|
||||
.unique();
|
||||
if (workArtifact) {
|
||||
await ctx.db.patch("projectArtifacts", workArtifact._id, {
|
||||
content: `${workArtifact.content}\n## Issue ${number}: ${title}\n\nStatus: open\n\n${body}\n`,
|
||||
revision: workArtifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("projectSignals", {
|
||||
projectId: args.projectId,
|
||||
issueId,
|
||||
kind: "issue.created",
|
||||
data: { number, title },
|
||||
createdAt: timestamp,
|
||||
});
|
||||
return issueId;
|
||||
},
|
||||
});
|
||||
|
||||
export const begin = mutation({
|
||||
args: { issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectOwner(ctx, issue.projectId, ownerId);
|
||||
if (issue.status === "queued" || issue.status === "working") {
|
||||
return issue.status;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: "queued",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
projectId: issue.projectId,
|
||||
issueId: issue._id,
|
||||
kind: "issue.queued",
|
||||
data: { number: issue.number },
|
||||
createdAt: timestamp,
|
||||
});
|
||||
return "queued" as const;
|
||||
},
|
||||
});
|
||||
|
||||
export const markDispatchFailed = mutation({
|
||||
args: { issueId: v.id("projectIssues"), error: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectOwner(ctx, issue.projectId, ownerId);
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: "failed",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
projectId: issue.projectId,
|
||||
issueId: issue._id,
|
||||
kind: "agent.failed",
|
||||
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
|
||||
createdAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { action, internalMutation, query } from "./_generated/server";
|
||||
import { createInitialArtifacts } from "./artifactModel";
|
||||
import { requireOwnerId } from "./authz";
|
||||
|
||||
type GitHubRepository = {
|
||||
readonly defaultBranch: string;
|
||||
readonly description?: string;
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly owner: string;
|
||||
readonly url: string;
|
||||
};
|
||||
|
||||
const parseRepository = (value: string): { owner: string; repo: string } => {
|
||||
const normalized = value.trim().replace(/\.git$/u, "");
|
||||
const match = normalized.match(
|
||||
/^(?:https?:\/\/github\.com\/)?([^/\s]+)\/([^/\s]+)$/iu
|
||||
);
|
||||
if (!match?.[1] || !match[2]) {
|
||||
throw new ConvexError("Use a GitHub repository in owner/name format");
|
||||
}
|
||||
return { owner: match[1], repo: match[2] };
|
||||
};
|
||||
|
||||
const readGitHubRepository = (value: unknown): GitHubRepository => {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
throw new ConvexError("GitHub returned an invalid repository response");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const owner = record.owner;
|
||||
if (
|
||||
typeof record.id !== "number" ||
|
||||
typeof record.name !== "string" ||
|
||||
typeof record.html_url !== "string" ||
|
||||
typeof record.default_branch !== "string" ||
|
||||
typeof owner !== "object" ||
|
||||
owner === null ||
|
||||
typeof (owner as Record<string, unknown>).login !== "string"
|
||||
) {
|
||||
throw new ConvexError("GitHub returned incomplete repository metadata");
|
||||
}
|
||||
return {
|
||||
defaultBranch: record.default_branch,
|
||||
...(typeof record.description === "string"
|
||||
? { description: record.description }
|
||||
: {}),
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
owner: (owner as { login: string }).login,
|
||||
url: record.html_url,
|
||||
};
|
||||
};
|
||||
|
||||
export const connectGitHub = action({
|
||||
args: { repository: v.string() },
|
||||
handler: async (ctx, args): Promise<Id<"projects">> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const { owner, repo } = parseRepository(args.repository);
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "zopu-code",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new ConvexError(
|
||||
"Repository not found. This first flow supports public GitHub repositories."
|
||||
);
|
||||
}
|
||||
throw new ConvexError(`GitHub connection failed with ${response.status}`);
|
||||
}
|
||||
const repository = readGitHubRepository(await response.json());
|
||||
const projectId: Id<"projects"> = await ctx.runMutation(
|
||||
internal.projects.storeGitHubProject,
|
||||
{ ownerId, repository }
|
||||
);
|
||||
return projectId;
|
||||
},
|
||||
});
|
||||
|
||||
export const storeGitHubProject = internalMutation({
|
||||
args: {
|
||||
ownerId: v.string(),
|
||||
repository: v.object({
|
||||
defaultBranch: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
id: v.number(),
|
||||
name: v.string(),
|
||||
owner: v.string(),
|
||||
url: v.string(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_owner_and_repository", (q) =>
|
||||
q
|
||||
.eq("ownerId", args.ownerId)
|
||||
.eq("provider", "github")
|
||||
.eq("repoOwner", args.repository.owner)
|
||||
.eq("repoName", args.repository.name)
|
||||
)
|
||||
.unique();
|
||||
const timestamp = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch("projects", existing._id, {
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
description: args.repository.description,
|
||||
providerRepoId: args.repository.id,
|
||||
repoUrl: args.repository.url,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
ownerId: args.ownerId,
|
||||
provider: "github",
|
||||
providerRepoId: args.repository.id,
|
||||
name: args.repository.name,
|
||||
description: args.repository.description,
|
||||
repoOwner: args.repository.owner,
|
||||
repoName: args.repository.name,
|
||||
repoUrl: args.repository.url,
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const artifacts = createInitialArtifacts({
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
description: args.repository.description,
|
||||
name: args.repository.name,
|
||||
repoName: args.repository.name,
|
||||
repoOwner: args.repository.owner,
|
||||
repoUrl: args.repository.url,
|
||||
});
|
||||
await Promise.all(
|
||||
artifacts.map(({ content, path }) =>
|
||||
ctx.db.insert("projectArtifacts", {
|
||||
projectId,
|
||||
path,
|
||||
content,
|
||||
revision: 1,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
)
|
||||
);
|
||||
await ctx.db.insert("projectSignals", {
|
||||
projectId,
|
||||
kind: "project.connected",
|
||||
data: { provider: "github", repository: args.repository.url },
|
||||
createdAt: timestamp,
|
||||
});
|
||||
return projectId;
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
return await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId))
|
||||
.order("desc")
|
||||
.take(50);
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
return project?.ownerId === ownerId ? project : null;
|
||||
},
|
||||
});
|
||||
@@ -62,6 +62,86 @@ export default defineSchema({
|
||||
})
|
||||
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
|
||||
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
|
||||
projects: defineTable({
|
||||
ownerId: v.string(),
|
||||
provider: v.literal("github"),
|
||||
providerRepoId: v.number(),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
repoOwner: v.string(),
|
||||
repoName: v.string(),
|
||||
repoUrl: v.string(),
|
||||
defaultBranch: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_owner_and_createdAt", ["ownerId", "createdAt"])
|
||||
.index("by_owner_and_repository", [
|
||||
"ownerId",
|
||||
"provider",
|
||||
"repoOwner",
|
||||
"repoName",
|
||||
]),
|
||||
projectArtifacts: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
revision: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project", ["projectId"])
|
||||
.index("by_project_and_path", ["projectId", "path"]),
|
||||
projectIssues: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
number: v.number(),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
status: v.union(
|
||||
v.literal("open"),
|
||||
v.literal("queued"),
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project_and_number", ["projectId", "number"])
|
||||
.index("by_project_and_status", ["projectId", "status"]),
|
||||
projectWorkRuns: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
issueId: v.id("projectIssues"),
|
||||
agentId: v.string(),
|
||||
daemonId: v.string(),
|
||||
actorKey: v.array(v.string()),
|
||||
status: v.union(
|
||||
v.literal("ready"),
|
||||
v.literal("queued"),
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
summary: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
startedAt: v.optional(v.number()),
|
||||
completedAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_issue", ["issueId"])
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
|
||||
projectSignals: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
issueId: v.optional(v.id("projectIssues")),
|
||||
runId: v.optional(v.id("projectWorkRuns")),
|
||||
kind: v.string(),
|
||||
data: v.any(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"])
|
||||
.index("by_issue_and_createdAt", ["issueId", "createdAt"]),
|
||||
todos: defineTable({
|
||||
text: v.string(),
|
||||
completed: v.boolean(),
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@code/ui/lib/utils"
|
||||
import { Label } from "@code/ui/components/label"
|
||||
import { Separator } from "@code/ui/components/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-2.5 font-medium data-[variant=label]:text-xs data-[variant=legend]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-xs/relaxed leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-xs font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@code/ui/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
Reference in New Issue
Block a user