intial files
This commit is contained in:
37
packages/auth/package.json
Normal file
37
packages/auth/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
23
packages/auth/src/native/auth-client.ts
Normal file
23
packages/auth/src/native/auth-client.ts
Normal file
@@ -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 }),
|
||||
],
|
||||
});
|
||||
16
packages/auth/src/native/auth-provider.tsx
Normal file
16
packages/auth/src/native/auth-provider.tsx
Normal file
@@ -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>
|
||||
);
|
||||
62
packages/auth/src/native/hooks.ts
Normal file
62
packages/auth/src/native/hooks.ts
Normal file
@@ -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 };
|
||||
4
packages/auth/src/native/index.ts
Normal file
4
packages/auth/src/native/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { authClient } from "./auth-client";
|
||||
export { NativeAuthProvider } from "./auth-provider";
|
||||
export { NativeLoginForm } from "./login-form";
|
||||
export { NativeSignupForm } from "./signup-form";
|
||||
107
packages/auth/src/native/login-form.tsx
Normal file
107
packages/auth/src/native/login-form.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
138
packages/auth/src/native/signup-form.tsx
Normal file
138
packages/auth/src/native/signup-form.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
1
packages/auth/src/server/index.ts
Normal file
1
packages/auth/src/server/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { authComponent, createAuth } from "@code/backend/convex/auth";
|
||||
38
packages/auth/src/shared/forms.ts
Normal file
38
packages/auth/src/shared/forms.ts
Normal file
@@ -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;
|
||||
};
|
||||
8
packages/auth/src/web/auth-client.ts
Normal file
8
packages/auth/src/web/auth-client.ts
Normal file
@@ -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()],
|
||||
});
|
||||
14
packages/auth/src/web/auth-provider.tsx
Normal file
14
packages/auth/src/web/auth-provider.tsx
Normal file
@@ -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>
|
||||
);
|
||||
51
packages/auth/src/web/hooks.ts
Normal file
51
packages/auth/src/web/hooks.ts
Normal file
@@ -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 },
|
||||
});
|
||||
4
packages/auth/src/web/index.ts
Normal file
4
packages/auth/src/web/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { authClient } from "./auth-client";
|
||||
export { WebAuthProvider } from "./auth-provider";
|
||||
export { LoginForm } from "./login-form";
|
||||
export { SignupForm } from "./signup-form";
|
||||
114
packages/auth/src/web/login-form.tsx
Normal file
114
packages/auth/src/web/login-form.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
147
packages/auth/src/web/signup-form.tsx
Normal file
147
packages/auth/src/web/signup-form.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
10
packages/auth/tsconfig.json
Normal file
10
packages/auth/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
94
packages/backend/convex/artifactModel.ts
Normal file
94
packages/backend/convex/artifactModel.ts
Normal file
@@ -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",
|
||||
},
|
||||
];
|
||||
};
|
||||
17
packages/backend/convex/authz.ts
Normal file
17
packages/backend/convex/authz.ts
Normal file
@@ -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;
|
||||
};
|
||||
19
packages/backend/convex/projectArtifacts.ts
Normal file
19
packages/backend/convex/projectArtifacts.ts
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
145
packages/backend/convex/projectIssues.ts
Normal file
145
packages/backend/convex/projectIssues.ts
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
184
packages/backend/convex/projects.ts
Normal file
184
packages/backend/convex/projects.ts
Normal file
@@ -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(),
|
||||
|
||||
236
packages/ui/src/components/field.tsx
Normal file
236
packages/ui/src/components/field.tsx
Normal file
@@ -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,
|
||||
}
|
||||
25
packages/ui/src/components/separator.tsx
Normal file
25
packages/ui/src/components/separator.tsx
Normal file
@@ -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