refactor (v2): better arch

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-06-07 21:19:54 +02:00
parent 0753f3d256
commit 839308ec19
166 changed files with 8553 additions and 884 deletions

View File

@@ -59,14 +59,20 @@ export function CreateNoteDialog({ trigger, open: controlledOpen, onOpenChange }
{defaultTrigger}
</DialogTrigger>
)}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("Create Note")}</DialogTitle>
<DialogDescription>
{t("Add a new note to your collection.")}
</DialogDescription>
</DialogHeader>
<NoteForm onSubmit={onSubmit} isLoading={isPending} submitLabel={t("Create")} />
<DialogContent className="sm:max-w-[425px] max-h-[90dvh] flex flex-col gap-0 p-0">
<div className="px-6 pt-6 pb-4 shrink-0">
<DialogHeader>
<DialogTitle>{t("Create Note")}</DialogTitle>
<DialogDescription>
{t("Add a new note to your collection.")}
</DialogDescription>
</DialogHeader>
</div>
{/* Scrollable form body — ensures the submit button stays reachable when the
iOS keyboard pushes the viewport up. dvh accounts for the keyboard height. */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 pb-6">
<NoteForm onSubmit={onSubmit} isLoading={isPending} submitLabel={t("Create")} />
</div>
</DialogContent>
</Dialog>
);

View File

@@ -49,7 +49,8 @@ export function Editor({ value, onChange, placeholder, className }: EditorProps)
editorProps: {
attributes: {
class: cn(
"min-h-[100px] max-h-[400px] overflow-y-auto w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 prose dark:prose-invert max-w-none break-all min-w-0",
// text-base (16px) on all sizes prevents iOS Safari auto-zoom on focus.
"min-h-[100px] max-h-[40dvh] overflow-y-auto w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 prose dark:prose-invert max-w-none break-all min-w-0",
className
),
},

View File

@@ -165,14 +165,14 @@ export function NoteCard({ note }: NoteCardProps) {
</Card>
<Dialog open={editing} onOpenChange={setEditing}>
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col p-6 gap-0 overflow-hidden">
<DialogContent className="max-w-3xl max-h-[85dvh] flex flex-col p-6 gap-0 overflow-hidden">
<DialogHeader className="pb-4 shrink-0">
<DialogTitle>{t("Edit Note")}</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6">
<NoteForm
defaultValues={{
title: note.title,
title: note.title ?? undefined,
content: note.content,
is_pinned: note.is_pinned,
color: note.color,
@@ -189,7 +189,7 @@ export function NoteCard({ note }: NoteCardProps) {
open={historyOpen}
onOpenChange={setHistoryOpen}
noteId={note.id}
noteTitle={note.title}
noteTitle={note.title ?? ""}
/>
<NoteViewDialog

View File

@@ -24,7 +24,7 @@ export function NoteViewDialog({ note, open, onOpenChange, onEdit, onSelectNote
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={clsx("max-w-3xl max-h-[85vh] flex flex-col p-6 gap-0 overflow-hidden", colorClass)}>
<DialogContent className={clsx("max-w-3xl max-h-[85dvh] flex flex-col p-6 gap-0 overflow-hidden", colorClass)}>
<DialogHeader className="pb-4 shrink-0">
<div className="flex justify-between items-start gap-4">
<DialogTitle className="text-2xl font-bold leading-tight wrap-break-word">

View File

@@ -38,16 +38,16 @@ export function RelatedNotes({ noteId, onSelectNote }: RelatedNotesProps) {
</h3>
<div className="flex flex-wrap gap-2">
{relatedLinks.map((link) => {
const targetNote = notes?.find((n: any) => n.id === link.target_note_id);
const targetNote = notes?.find((n: any) => n.id === link.target_id);
if (!targetNote) return null;
return (
<Button
key={link.target_note_id}
key={link.target_id}
variant="outline"
size="sm"
className="h-8 text-xs max-w-[200px] justify-start"
onClick={() => onSelectNote?.(link.target_note_id)}
onClick={() => onSelectNote?.(link.target_id)}
>
<span className="truncate">{targetNote.title || "Untitled"}</span>
<Badge variant="secondary" className="ml-2 text-[10px] h-5 px-1">

View File

@@ -28,7 +28,7 @@ export function VersionHistoryDialog({
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${version.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}-${format(new Date(version.created_at), "yyyy-MM-dd-HH-mm")}.txt`;
a.download = `${(version.title ?? "untitled").replace(/[^a-z0-9]/gi, '_').toLowerCase()}-${format(new Date(version.created_at), "yyyy-MM-dd-HH-mm")}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -40,7 +40,7 @@ export function VersionHistoryDialog({
if (confirm("Are you sure you want to restore this version? The current version will be saved as a new history entry.")) {
updateNote({
id: noteId,
title: version.title,
title: version.title ?? undefined,
content: version.content,
}, {
onSuccess: () => {
@@ -53,7 +53,7 @@ export function VersionHistoryDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col">
<DialogContent className="max-w-2xl max-h-[85dvh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5" />

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, setAuthToken, clearAuthToken, getBaseUrl } from "@/lib/api";
import { api, setAuthToken, clearAuthToken } from "@/lib/api";
import { useNavigate } from "react-router-dom";
export interface User {
@@ -8,29 +8,16 @@ export interface User {
created_at: string;
}
// Token response from JWT/OIDC login
export interface TokenResponse {
export interface AuthResponse {
user: User;
access_token: string;
token_type: string;
expires_in: number;
}
// Login can return either User (session mode) or Token (JWT mode)
export type LoginResult = User | TokenResponse;
function isTokenResponse(result: LoginResult): result is TokenResponse {
return 'access_token' in result;
}
// Fetch current user
async function fetchUser(): Promise<User | null> {
try {
const user = await api.get("/auth/me");
return user;
return await api.get("/auth/me");
} catch (error: any) {
if (error.status === 401) {
return null; // Not logged in
}
if (error.status === 401) return null;
throw error;
}
}
@@ -39,8 +26,8 @@ export function useUser() {
return useQuery({
queryKey: ["user"],
queryFn: fetchUser,
retry: false, // Don't retry on 401
staleTime: 1000 * 60 * 5, // 5 minutes
retry: false,
staleTime: 1000 * 60 * 5,
});
}
@@ -49,13 +36,10 @@ export function useLogin() {
const navigate = useNavigate();
return useMutation({
mutationFn: (credentials: { email: string; password: string }): Promise<LoginResult> =>
mutationFn: (credentials: { email: string; password: string }): Promise<AuthResponse> =>
api.post("/auth/login", credentials),
onSuccess: (result: LoginResult) => {
// If we got a token response, store the token
if (isTokenResponse(result)) {
setAuthToken(result.access_token);
}
onSuccess: (result: AuthResponse) => {
setAuthToken(result.access_token);
queryClient.invalidateQueries({ queryKey: ["user"] });
navigate("/");
},
@@ -67,13 +51,10 @@ export function useRegister() {
const navigate = useNavigate();
return useMutation({
mutationFn: (credentials: { email: string; password: string }): Promise<LoginResult> =>
mutationFn: (credentials: { email: string; password: string }): Promise<AuthResponse> =>
api.post("/auth/register", credentials),
onSuccess: (result: LoginResult) => {
// If we got a token response, store the token
if (isTokenResponse(result)) {
setAuthToken(result.access_token);
}
onSuccess: (result: AuthResponse) => {
setAuthToken(result.access_token);
queryClient.invalidateQueries({ queryKey: ["user"] });
navigate("/");
},
@@ -85,27 +66,12 @@ export function useLogout() {
const navigate = useNavigate();
return useMutation({
mutationFn: () => api.post("/auth/logout", {}),
// JWT logout is client-side: discard the token.
mutationFn: () => Promise.resolve(),
onSuccess: () => {
// Clear both session data and JWT token
clearAuthToken();
queryClient.setQueryData(["user"], null);
navigate("/login");
},
onError: () => {
// Even on error, clear local state
clearAuthToken();
queryClient.setQueryData(["user"], null);
navigate("/login");
},
});
}
// Hook to initiate OIDC login flow
export function useOidcLogin() {
return () => {
// Redirect to OIDC login endpoint
window.location.href = `${getBaseUrl()}/api/v1/auth/login/oidc`;
};
}

View File

@@ -5,7 +5,7 @@ import { toast } from "sonner";
export interface Note {
id: string;
title: string;
title: string | null;
content: string;
is_pinned: boolean;
is_archived: boolean;
@@ -18,11 +18,10 @@ export interface Note {
export interface Tag {
id: string;
name: string;
created_at?: string;
}
export interface CreateNoteInput {
title: string;
title?: string;
content: string;
tags?: string[];
color?: string;
@@ -40,7 +39,6 @@ export interface UpdateNoteInput {
}
export function useNotes(params?: { pinned?: boolean; archived?: boolean; tag?: string }) {
// Construct query string
const searchParams = new URLSearchParams();
if (params?.pinned !== undefined) searchParams.set("pinned", String(params.pinned));
if (params?.archived !== undefined) searchParams.set("archived", String(params.archived));
@@ -65,38 +63,39 @@ export function useCreateNote() {
return useMutation({
mutationFn: async (data: CreateNoteInput) => {
const { tags, ...noteData } = data;
const queueOffline = async () => {
console.log("Queueing offline creation...");
await addToMutationQueue({
type: "POST",
endpoint: "/notes",
body: data,
});
console.log("Offline creation queued.");
await addToMutationQueue({ type: "POST", endpoint: "/notes", body: data });
toast.info("Note created offline. Will sync when online.");
return {
id: crypto.randomUUID(),
...data,
...noteData,
title: noteData.title ?? null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
is_pinned: data.is_pinned || false,
is_archived: false,
tags: [],
color: "default"
color: data.color || "DEFAULT",
};
};
if (!navigator.onLine) {
console.log("Navigator is offline, queueing");
return queueOffline();
}
if (!navigator.onLine) return queueOffline();
try {
return await api.post("/notes", data);
const note = await api.post("/notes", noteData);
// Tags are added separately via the dedicated endpoint.
if (tags && tags.length > 0) {
for (const tag_name of tags) {
await api.post(`/notes/${note.id}/tags`, { tag_name });
}
}
return note;
} catch (error: any) {
console.error("API Error in createNote:", error);
if (!navigator.onLine || error.name === 'AbortError' || error instanceof TypeError) {
console.log("Falling back to offline queue due to error");
if (!navigator.onLine || error.name === "AbortError" || error instanceof TypeError) {
return queueOffline();
}
throw error;
@@ -113,54 +112,85 @@ export function useUpdateNote() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: UpdateNoteInput) => {
mutationFn: async ({ id, is_pinned, is_archived, tags, ...noteData }: UpdateNoteInput) => {
const queueOffline = async () => {
await addToMutationQueue({
type: "PATCH",
endpoint: `/notes/${id}`,
body: data,
});
await addToMutationQueue({ type: "PATCH", endpoint: `/notes/${id}`, body: noteData });
toast.info("Note updated offline. Will sync when online.");
return { id, ...data };
return { id, ...noteData };
};
if (!navigator.onLine) {
return queueOffline();
}
if (!navigator.onLine) return queueOffline();
try {
return await api.patch(`/notes/${id}`, data);
// Core fields: title, content, color.
if (Object.keys(noteData).length > 0) {
await api.patch(`/notes/${id}`, noteData);
}
// Pin / archive via dedicated endpoints.
if (is_pinned !== undefined) {
await api.patch(`/notes/${id}/pin`, { pinned: is_pinned });
}
if (is_archived !== undefined) {
await api.patch(`/notes/${id}/archive`, { archived: is_archived });
}
// Tag changes: diff current tags (from cache or fresh fetch) against desired list.
if (tags !== undefined) {
const allNotes = queryClient.getQueriesData<Note[]>({ queryKey: ["notes"] });
let currentNote: Note | undefined;
for (const [, notes] of allNotes) {
currentNote = (notes as Note[])?.find(n => n.id === id);
if (currentNote) break;
}
// Fallback: fetch from server if not in cache (or cache was corrupted).
if (!currentNote || currentNote.tags.some(t => !t.id)) {
currentNote = await api.get(`/notes/${id}`);
}
if (currentNote) {
for (const existing of currentNote.tags) {
if (!tags.includes(existing.name)) {
await api.delete(`/notes/${id}/tags/${existing.id}`);
}
}
for (const tag_name of tags) {
if (!currentNote.tags.some(t => t.name === tag_name)) {
await api.post(`/notes/${id}/tags`, { tag_name });
}
}
}
}
return { id };
} catch (error: any) {
if (!navigator.onLine || error.name === 'AbortError' || error instanceof TypeError) {
if (!navigator.onLine || error.name === "AbortError" || error instanceof TypeError) {
return queueOffline();
}
throw error;
}
},
// Optimistic update
onMutate: async (updatedNote) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ["notes"] });
// Snapshot the previous value
const previousNotes = queryClient.getQueriesData({ queryKey: ["notes"] });
// Optimistically update all matching queries
// Exclude tags from the optimistic update: tags are string[] in UpdateNoteInput
// but Tag[] in the cache. Spreading strings would corrupt the cached Tag objects
// (losing .id) which the mutationFn reads for the add/remove diff.
const { tags: _tags, ...optimisticData } = updatedNote;
queryClient.setQueriesData({ queryKey: ["notes"] }, (old: Note[] | undefined) => {
if (!old) return old;
return old.map((note) =>
note.id === updatedNote.id
? { ...note, ...updatedNote }
: note
return old.map(note =>
note.id === updatedNote.id ? { ...note, ...optimisticData } : note
);
});
// Return a context object with the snapshotted value
return { previousNotes };
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (_err, _updatedNote, context) => {
if (context?.previousNotes) {
context.previousNotes.forEach(([queryKey, data]) => {
@@ -169,7 +199,6 @@ export function useUpdateNote() {
}
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["notes"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
@@ -183,47 +212,35 @@ export function useDeleteNote() {
return useMutation({
mutationFn: async (id: string) => {
const queueOffline = async () => {
await addToMutationQueue({
type: "DELETE",
endpoint: `/notes/${id}`,
});
await addToMutationQueue({ type: "DELETE", endpoint: `/notes/${id}` });
toast.info("Note deleted offline. Will sync when online.");
return { id };
};
if (!navigator.onLine) {
return queueOffline();
}
if (!navigator.onLine) return queueOffline();
try {
return await api.delete(`/notes/${id}`);
} catch (error: any) {
if (!navigator.onLine || error.name === 'AbortError' || error instanceof TypeError) {
if (!navigator.onLine || error.name === "AbortError" || error instanceof TypeError) {
return queueOffline();
}
throw error;
}
},
// Optimistic delete
onMutate: async (deletedId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ["notes"] });
// Snapshot the previous value
const previousNotes = queryClient.getQueriesData({ queryKey: ["notes"] });
// Optimistically remove from all matching queries
queryClient.setQueriesData({ queryKey: ["notes"] }, (old: Note[] | undefined) => {
if (!old) return old;
return old.filter((note) => note.id !== deletedId);
return old.filter(note => note.id !== deletedId);
});
// Return a context object with the snapshotted value
return { previousNotes };
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (_err, _deletedId, context) => {
if (context?.previousNotes) {
context.previousNotes.forEach(([queryKey, data]) => {
@@ -232,7 +249,6 @@ export function useDeleteNote() {
}
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["notes"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
@@ -243,7 +259,7 @@ export function useDeleteNote() {
export interface NoteVersion {
id: string;
note_id: string;
title: string;
title: string | null;
content: string;
created_at: string;
}

View File

@@ -2,8 +2,8 @@ import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
export interface NoteLink {
source_note_id: string;
target_note_id: string;
source_id: string;
target_id: string;
score: number;
created_at: string;
}

View File

@@ -1,21 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
export type AuthMode = 'session' | 'jwt' | 'both';
export interface ConfigResponse {
allow_registration: boolean;
auth_mode: AuthMode;
oidc_enabled: boolean;
password_login_enabled: boolean;
}
export function useConfig() {
return useQuery<ConfigResponse>({
queryKey: ["config"],
queryFn: () => api.get("/config"),
staleTime: Infinity, // Config rarely changes
staleTime: Infinity,
});
}

View File

@@ -22,17 +22,17 @@ export function clearAuthToken(): void {
}
const getApiUrl = () => {
// 1. Runtime config (Docker)
// 1. Runtime config injected via window.env (e.g. for cross-origin setups)
if (window.env?.API_URL) {
return `${window.env.API_URL}/api/v1`;
}
// 2. LocalStorage override
// 2. LocalStorage override (user-configurable in settings)
const stored = localStorage.getItem("k_notes_api_url");
if (stored) {
return `${stored}/api/v1`;
}
// 3. Default fallback
return "http://localhost:3000/api/v1";
// 3. Same-origin fallback — works when SPA is served by the backend process
return "/api/v1";
};
export const getBaseUrl = () => {

View File

@@ -1,11 +1,11 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Settings, ExternalLink } from "lucide-react";
import { Settings } from "lucide-react";
import { SettingsDialog } from "@/components/settings-dialog";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Link } from "react-router-dom";
import { useLogin, useOidcLogin } from "@/hooks/use-auth";
import { useLogin } from "@/hooks/use-auth";
import { useConfig } from "@/hooks/useConfig";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -17,7 +17,7 @@ import { useTranslation } from "react-i18next";
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
@@ -26,14 +26,11 @@ export default function LoginPage() {
const { mutate: login, isPending } = useLogin();
const { data: config } = useConfig();
const { t } = useTranslation();
const startOidcLogin = useOidcLogin();
const [settingsOpen, setSettingsOpen] = useState(false);
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
defaultValues: { email: "", password: "" },
});
const onSubmit = (data: LoginFormValues) => {
@@ -48,8 +45,6 @@ export default function LoginPage() {
});
};
const [settingsOpen, setSettingsOpen] = useState(false);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-950 p-4 relative">
<div className="absolute top-4 right-4">
@@ -65,70 +60,39 @@ export default function LoginPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* OIDC/SSO Login Button */}
{config?.oidc_enabled && (
<>
<Button
type="button"
variant="outline"
className="w-full"
onClick={startOidcLogin}
>
<ExternalLink className="mr-2 h-4 w-4" />
{t("Sign in with SSO")}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Email")}</FormLabel>
<FormControl>
<Input placeholder="name@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Password")}</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? t("Signing in...") : t("Sign in")}
</Button>
{/* Divider only if both OIDC and password login are enabled */}
{config?.password_login_enabled && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
{t("Or continue with")}
</span>
</div>
</div>
)}
</>
)}
{/* Email/Password Form - only show if password login is enabled */}
{config?.password_login_enabled !== false && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Email")}</FormLabel>
<FormControl>
<Input placeholder="name@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Password")}</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? t("Signing in...") : t("Sign in")}
</Button>
</form>
</Form>
)}
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-center">
{config?.allow_registration !== false && (
@@ -145,4 +109,3 @@ export default function LoginPage() {
</div>
);
}

View File

@@ -17,8 +17,8 @@ import { useTranslation } from "react-i18next";
const registerSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Password must be at least 6 characters"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string().min(8, "Password must be at least 8 characters"),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
@@ -31,36 +31,26 @@ export default function RegisterPage() {
const { data: config, isLoading: isConfigLoading } = useConfig();
const navigate = useNavigate();
const { t } = useTranslation();
const [settingsOpen, setSettingsOpen] = useState(false);
useEffect(() => {
if (!isConfigLoading && config?.allow_registration === false) {
toast.error(t("Registration is currently disabled"));
navigate("/login");
} else if (!isConfigLoading && config?.password_login_enabled === false) {
// Registration requires password login to be enabled
toast.error(t("Registration is not available"));
navigate("/login");
}
}, [config, isConfigLoading, navigate, t]);
if (isConfigLoading || config?.allow_registration === false || config?.password_login_enabled === false) {
return null; // Or a loading spinner
if (isConfigLoading || config?.allow_registration === false) {
return null;
}
const form = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
email: "",
password: "",
confirmPassword: "",
},
defaultValues: { email: "", password: "", confirmPassword: "" },
});
const onSubmit = (data: RegisterFormValues) => {
register({
email: data.email,
password: data.password,
}, {
register({ email: data.email, password: data.password }, {
onError: (error: any) => {
if (error instanceof ApiError) {
toast.error(error.message);
@@ -71,8 +61,6 @@ export default function RegisterPage() {
});
};
const [settingsOpen, setSettingsOpen] = useState(false);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-950 p-4 relative">
<div className="absolute top-4 right-4">