intial files
This commit is contained in:
5
apps/native/app/(auth)/_layout.tsx
Normal file
5
apps/native/app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
24
apps/native/app/(auth)/login.tsx
Normal file
24
apps/native/app/(auth)/login.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NativeLoginForm } from "@code/auth/native";
|
||||
import { router } from "expo-router";
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
|
||||
|
||||
export default function LoginScreen() {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
className="flex-1 bg-background"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerClassName="flex-grow justify-center p-6"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="mx-auto w-full max-w-md">
|
||||
<NativeLoginForm
|
||||
onNavigateSignUp={() => router.push("/(auth)/signup")}
|
||||
onSuccess={() => router.replace("/")}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
24
apps/native/app/(auth)/signup.tsx
Normal file
24
apps/native/app/(auth)/signup.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NativeSignupForm } from "@code/auth/native";
|
||||
import { router } from "expo-router";
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
|
||||
|
||||
export default function SignupScreen() {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
className="flex-1 bg-background"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerClassName="flex-grow justify-center p-6"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="mx-auto w-full max-w-md py-6">
|
||||
<NativeSignupForm
|
||||
onNavigateSignIn={() => router.push("/(auth)/login")}
|
||||
onSuccess={() => router.replace("/")}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +1,53 @@
|
||||
import { authClient } from "@code/auth/native";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import { Button, Chip, Separator, Spinner, Surface, useThemeColor } from "heroui-native";
|
||||
import { useQuery } from "convex/react";
|
||||
import { Button, Spinner, Surface } from "heroui-native";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
import { SignIn } from "@/components/sign-in";
|
||||
import { SignUp } from "@/components/sign-up";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function Home() {
|
||||
const healthCheck = useQuery(api.healthCheck.get);
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const user = useQuery(api.auth.getCurrentUser, isAuthenticated ? {} : "skip");
|
||||
const successColor = useThemeColor("success");
|
||||
const dangerColor = useThemeColor("danger");
|
||||
|
||||
const isConnected = healthCheck === "OK";
|
||||
const isLoading = healthCheck === undefined;
|
||||
const user = useQuery(api.auth.getCurrentUser);
|
||||
|
||||
return (
|
||||
<Container className="px-4 pb-4">
|
||||
<View className="py-6 mb-5">
|
||||
<Text className="text-3xl font-semibold text-foreground tracking-tight">
|
||||
Better T Stack
|
||||
</Text>
|
||||
<Text className="text-muted text-sm mt-1">Full-stack TypeScript starter</Text>
|
||||
<View className="mb-5 py-6">
|
||||
<Text className="text-3xl font-semibold tracking-tight text-foreground">Zopu</Text>
|
||||
<Text className="mt-1 text-sm text-muted">Your authenticated workspace</Text>
|
||||
</View>
|
||||
|
||||
{user ? (
|
||||
<Surface variant="secondary" className="mb-4 p-4 rounded-xl">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Surface className="mb-4 rounded-xl p-4" variant="secondary">
|
||||
{user === undefined ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<View className="flex-row items-center justify-between gap-4">
|
||||
<View className="flex-1">
|
||||
<Text className="text-foreground font-medium">{user.name}</Text>
|
||||
<Text className="text-muted text-xs mt-0.5">{user.email}</Text>
|
||||
<Text className="font-medium text-foreground">{user?.name ?? "Signed in"}</Text>
|
||||
<Text className="mt-0.5 text-xs text-muted">{user?.email}</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
authClient.signOut();
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
<Button onPress={() => void authClient.signOut()} size="sm" variant="danger">
|
||||
<Button.Label>Sign out</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</Surface>
|
||||
) : null}
|
||||
<Surface variant="secondary" className="p-4 rounded-xl">
|
||||
<Text className="text-foreground font-medium mb-2">API Status</Text>
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
<Surface className="rounded-xl p-4" variant="secondary">
|
||||
<Text className="mb-2 font-medium text-foreground">API status</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View
|
||||
className={`w-2 h-2 rounded-full ${healthCheck === "OK" ? "bg-success" : "bg-danger"}`}
|
||||
className={`h-2 w-2 rounded-full ${healthCheck === "OK" ? "bg-success" : "bg-danger"}`}
|
||||
/>
|
||||
<Text className="text-muted text-xs">
|
||||
<Text className="text-xs text-muted">
|
||||
{healthCheck === undefined
|
||||
? "Checking..."
|
||||
? "Checking…"
|
||||
: healthCheck === "OK"
|
||||
? "Connected to API"
|
||||
: "API Disconnected"}
|
||||
? "Connected to Convex"
|
||||
: "Convex unavailable"}
|
||||
</Text>
|
||||
</View>
|
||||
</Surface>
|
||||
{!user && (
|
||||
<View className="mt-5 gap-4">
|
||||
<SignIn />
|
||||
<SignUp />
|
||||
</View>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,53 @@
|
||||
import "@/global.css";
|
||||
import { env } from "@code/env/native";
|
||||
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
|
||||
import { ConvexReactClient } from "convex/react";
|
||||
import { NativeAuthProvider } from "@code/auth/native";
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { Stack } from "expo-router";
|
||||
import { HeroUINativeProvider } from "heroui-native";
|
||||
import { HeroUINativeProvider, Spinner } from "heroui-native";
|
||||
import { View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
|
||||
import { AppThemeProvider } from "@/contexts/app-theme-context";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: "(drawer)",
|
||||
};
|
||||
function RootNavigator() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
|
||||
const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, {
|
||||
unsavedChangesWarning: false,
|
||||
});
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-background">
|
||||
<Spinner size="lg" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StackLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{}}>
|
||||
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ title: "Modal", presentation: "modal" }} />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Protected guard={!isAuthenticated}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={isAuthenticated}>
|
||||
<Stack.Screen name="(drawer)" />
|
||||
<Stack.Screen
|
||||
name="modal"
|
||||
options={{ headerShown: true, presentation: "modal", title: "Modal" }}
|
||||
/>
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<ConvexBetterAuthProvider client={convex} authClient={authClient}>
|
||||
<NativeAuthProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<KeyboardProvider>
|
||||
<AppThemeProvider>
|
||||
<HeroUINativeProvider>
|
||||
<StackLayout />
|
||||
<RootNavigator />
|
||||
</HeroUINativeProvider>
|
||||
</AppThemeProvider>
|
||||
</KeyboardProvider>
|
||||
</GestureHandlerRootView>
|
||||
</ConvexBetterAuthProvider>
|
||||
</NativeAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import {
|
||||
Button,
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
Spinner,
|
||||
Surface,
|
||||
TextField,
|
||||
useToast,
|
||||
} from "heroui-native";
|
||||
import { useRef } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import z from "zod";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
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"),
|
||||
});
|
||||
|
||||
function 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) {
|
||||
const maybeError = error as { message?: unknown };
|
||||
if (typeof maybeError.message === "string") {
|
||||
return maybeError.message;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SignIn() {
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: signInSchema,
|
||||
},
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
toast.show({
|
||||
variant: "danger",
|
||||
label: error.error?.message || "Failed to sign in",
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
formApi.reset();
|
||||
toast.show({
|
||||
variant: "success",
|
||||
label: "Signed in successfully",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Surface variant="secondary" className="p-4 rounded-lg">
|
||||
<Text className="text-foreground font-medium mb-4">Sign In</Text>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSubmitting: state.isSubmitting,
|
||||
validationError: getErrorMessage(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, validationError }) => {
|
||||
const formError = validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldError isInvalid={!!formError} className="mb-3">
|
||||
{formError}
|
||||
</FieldError>
|
||||
|
||||
<View className="gap-3">
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="email@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
textContentType="emailAddress"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
ref={passwordInputRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
textContentType="password"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={form.handleSubmit}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button onPress={form.handleSubmit} isDisabled={isSubmitting} className="mt-1">
|
||||
{isSubmitting ? (
|
||||
<Spinner size="sm" color="default" />
|
||||
) : (
|
||||
<Button.Label>Sign In</Button.Label>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import {
|
||||
Button,
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
Spinner,
|
||||
Surface,
|
||||
TextField,
|
||||
useToast,
|
||||
} from "heroui-native";
|
||||
import { useRef } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import z from "zod";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const signUpSchema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required").min(2, "Name must be at least 2 characters"),
|
||||
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"),
|
||||
});
|
||||
|
||||
function 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) {
|
||||
const maybeError = error as { message?: unknown };
|
||||
if (typeof maybeError.message === "string") {
|
||||
return maybeError.message;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SignUp() {
|
||||
const emailInputRef = useRef<TextInput>(null);
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: signUpSchema,
|
||||
},
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
name: value.name.trim(),
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
toast.show({
|
||||
variant: "danger",
|
||||
label: error.error?.message || "Failed to sign up",
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
formApi.reset();
|
||||
toast.show({
|
||||
variant: "success",
|
||||
label: "Account created successfully",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Surface variant="secondary" className="p-4 rounded-lg">
|
||||
<Text className="text-foreground font-medium mb-4">Create Account</Text>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSubmitting: state.isSubmitting,
|
||||
validationError: getErrorMessage(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, validationError }) => {
|
||||
const formError = validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldError isInvalid={!!formError} className="mb-3">
|
||||
{formError}
|
||||
</FieldError>
|
||||
|
||||
<View className="gap-3">
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="John Doe"
|
||||
autoComplete="name"
|
||||
textContentType="name"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => {
|
||||
emailInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
ref={emailInputRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="email@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
textContentType="emailAddress"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
ref={passwordInputRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
textContentType="newPassword"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={form.handleSubmit}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button onPress={form.handleSubmit} isDisabled={isSubmitting} className="mt-1">
|
||||
{isSubmitting ? (
|
||||
<Spinner size="sm" color="default" />
|
||||
) : (
|
||||
<Button.Label>Create Account</Button.Label>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: env.EXPO_PUBLIC_CONVEX_SITE_URL,
|
||||
plugins: [
|
||||
convexClient(),
|
||||
Platform.OS === "web"
|
||||
? crossDomainClient()
|
||||
: expoClient({
|
||||
scheme: Constants.expoConfig?.scheme as string,
|
||||
storagePrefix: Constants.expoConfig?.scheme as string,
|
||||
storage: SecureStore,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -12,24 +12,18 @@
|
||||
"web": "bun --env-file=../../.env expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/expo": "catalog:",
|
||||
"@code/auth": "workspace:*",
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@convex-dev/better-auth": "catalog:",
|
||||
"@expo/metro-runtime": "~57.0.2",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"expo": "~57.0.1",
|
||||
"expo-constants": "~57.0.2",
|
||||
"expo-font": "~57.0.0",
|
||||
"expo-haptics": "~57.0.0",
|
||||
"expo-linking": "~57.0.1",
|
||||
"expo-network": "~57.0.0",
|
||||
"expo-router": "~57.0.2",
|
||||
"expo-secure-store": "~57.0.0",
|
||||
"expo-status-bar": "~57.0.0",
|
||||
"expo-web-browser": "~57.0.0",
|
||||
"heroui-native": "^1.0.5",
|
||||
@@ -47,8 +41,7 @@
|
||||
"tailwind-merge": "catalog:",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "catalog:",
|
||||
"uniwind": "^1.10.0",
|
||||
"zod": "catalog:"
|
||||
"uniwind": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
|
||||
Reference in New Issue
Block a user