refactor (v2): better arch
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user