From e19582382dbb797cd109aadbe30087a3d7b4a7f5 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:22:15 +0530 Subject: [PATCH 01/66] feat(dashboard): enable view transition --- apps/dashboard/src/main.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 9412be58..803565c4 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -23,6 +23,7 @@ const router = createRouter({ basepath: "/dashboard", defaultPreload: "intent", scrollRestoration: true, + defaultViewTransition: true, defaultStructuralSharing: true, defaultPreloadStaleTime: 0, }); From 5e87fe73c83dd340378d8eaa9028aeb693f3f606 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:05:34 +0530 Subject: [PATCH 02/66] feat(dashboard): add useDebouncedValue hook --- apps/dashboard/src/hooks/use-debounced-value.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 apps/dashboard/src/hooks/use-debounced-value.ts diff --git a/apps/dashboard/src/hooks/use-debounced-value.ts b/apps/dashboard/src/hooks/use-debounced-value.ts new file mode 100644 index 00000000..b0ee6f9c --- /dev/null +++ b/apps/dashboard/src/hooks/use-debounced-value.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +/** + * Returns a copy of `value` that only updates after it has stopped changing for + * `delayMs`. Useful for keeping an input responsive while debouncing the value + * that drives a query. + */ +export function useDebouncedValue(value: T, delayMs = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(id); + }, [value, delayMs]); + + return debounced; +} From 2ae381dbc092a52aa8b73a35dc2b50e30d0706c6 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:05:46 +0530 Subject: [PATCH 03/66] feat(dashboard): debounce file picker search input --- .../src/components/file-picker-dialog.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/components/file-picker-dialog.tsx b/apps/dashboard/src/components/file-picker-dialog.tsx index 76bc7879..8ea2a4aa 100644 --- a/apps/dashboard/src/components/file-picker-dialog.tsx +++ b/apps/dashboard/src/components/file-picker-dialog.tsx @@ -8,7 +8,7 @@ import { Upload, Video, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; @@ -25,6 +25,7 @@ import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { type FileItem, getFiles, uploadFile } from "@/lib/api"; // --------------------------------------------------------------------------- @@ -120,6 +121,7 @@ export function FilePickerDialog({ uploadProvider = "filesystem", }: FilePickerDialogProps) { const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 300); const [page, setPage] = useState(1); const [tab, setTab] = useState("library"); const [dragOver, setDragOver] = useState(false); @@ -135,16 +137,22 @@ export function FilePickerDialog({ ); const { data, isLoading, isError } = useQuery({ - queryKey: ["files", siteId, page, search, fileType], + queryKey: ["files", siteId, page, debouncedSearch, fileType], queryFn: () => getFiles(siteId, { page, - search: search || undefined, + search: debouncedSearch || undefined, type: fileType, }), enabled: open, }); + // Reset to the first page when the debounced search term changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: reset only on term change + useEffect(() => { + setPage(1); + }, [debouncedSearch]); + // Client-side filtering on top of the server-side type filter, to handle // cases where the accept prop contains multiple specific MIME types. const filteredItems = useMemo(() => { @@ -203,7 +211,6 @@ export function FilePickerDialog({ const handleSearchChange = useCallback( (e: React.ChangeEvent) => { setSearch(e.target.value); - setPage(1); }, [], ); From 81ad639855cb6fa902f9ed33c0c13609d81a4048 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:05:58 +0530 Subject: [PATCH 04/66] feat(dashboard): debounce entries list search input --- .../entries.$collectionSlug/index.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/src/routes/_admin/sites.$siteId/entries.$collectionSlug/index.tsx b/apps/dashboard/src/routes/_admin/sites.$siteId/entries.$collectionSlug/index.tsx index 7da4998b..e5fc5299 100644 --- a/apps/dashboard/src/routes/_admin/sites.$siteId/entries.$collectionSlug/index.tsx +++ b/apps/dashboard/src/routes/_admin/sites.$siteId/entries.$collectionSlug/index.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { Plus, Search } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { createColumns } from "@/components/entries/columns"; import { buttonVariants } from "@/components/ui/button"; @@ -15,6 +15,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { deleteEntry, getCollection, @@ -33,10 +34,18 @@ function EntriesListPage() { const { siteId, collectionSlug } = Route.useParams(); const queryClient = useQueryClient(); const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 300); const [statusFilter, setStatusFilter] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); + // Reset to the first page when the debounced search term actually changes, + // so paging tracks the fetched results rather than each keystroke. + // biome-ignore lint/correctness/useExhaustiveDependencies: reset only on term change + useEffect(() => { + setPage(1); + }, [debouncedSearch]); + const { data: collection, isLoading: collectionLoading } = useQuery({ queryKey: ["collection", siteId, collectionSlug], queryFn: () => getCollection(siteId, collectionSlug), @@ -48,7 +57,7 @@ function EntriesListPage() { siteId, collectionSlug, statusFilter, - search, + debouncedSearch, page, pageSize, ], @@ -56,7 +65,7 @@ function EntriesListPage() { getEntries(siteId, { type: collectionSlug, status: statusFilter || undefined, - search: search || undefined, + search: debouncedSearch || undefined, page, pageSize, }), @@ -67,7 +76,6 @@ function EntriesListPage() { const handleSearchChange = (value: string | null) => { setSearch(value || ""); - setPage(1); }; const handleStatusChange = (value: string | null) => { @@ -79,7 +87,7 @@ function EntriesListPage() { mutationFn: (id: string) => deleteEntry(siteId, id), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ["entries", siteId, collectionSlug], + queryKey: ["entries", siteId], }); toast.success("Entry deleted"); }, @@ -90,7 +98,7 @@ function EntriesListPage() { mutationFn: (id: string) => publishEntry(siteId, id), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ["entries", siteId, collectionSlug], + queryKey: ["entries", siteId], }); toast.success("Published"); }, @@ -101,7 +109,7 @@ function EntriesListPage() { mutationFn: (id: string) => unpublishEntry(siteId, id), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ["entries", siteId, collectionSlug], + queryKey: ["entries", siteId], }); toast.success("Unpublished"); }, @@ -166,7 +174,7 @@ function EntriesListPage() { { - setSearch(e.target.value); - setPage(1); - }} + onChange={(e) => setSearch(e.target.value)} className="pl-8" /> From 4140c08665b7c30a36413c0831348ea5419286f5 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:06:46 +0530 Subject: [PATCH 06/66] feat(backend): reconcile orphan backup/restore jobs at startup --- apps/backend/src/main.rs | 8 +++++ apps/backend/src/services/backup/meta.rs | 40 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 5ad7fa23..511824e4 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -84,6 +84,14 @@ async fn run_serve(cli: &Cli) -> Result<(), Box> { backup_service.clone(), ); + // Reconcile backups/restore jobs left mid-flight by a previous process: any + // running/pending row at startup is orphaned (backups only run in-process). + match cms::services::backup::meta::fail_orphaned(&pool, &cms::services::backup::now_iso()).await { + Ok(n) if n > 0 => info!("Reconciled {n} interrupted backup/restore job(s) to failed"), + Ok(_) => {} + Err(e) => tracing::error!("Failed to reconcile interrupted backups: {e}"), + } + if config.backup_enabled { let scheduler_service = backup_service.clone(); tokio::spawn(async move { diff --git a/apps/backend/src/services/backup/meta.rs b/apps/backend/src/services/backup/meta.rs index b71088e4..4ebb5f10 100644 --- a/apps/backend/src/services/backup/meta.rs +++ b/apps/backend/src/services/backup/meta.rs @@ -206,6 +206,46 @@ pub async fn mark_failed(pool: &DbPool, id: &str, error: &str, now: &str) -> Res Ok(()) } +/// Fail backups/restore jobs left mid-flight by a previous process. Any +/// `running`/`pending` row at startup is orphaned, since backups only ever run +/// in-process. Returns the number of backup rows reconciled (for logging). +pub async fn fail_orphaned(pool: &DbPool, now: &str) -> Result { + let backups_sql = q( + pool.backend(), + "UPDATE backups SET status = 'failed', error = 'interrupted: server stopped during backup', \ + completed_at = ? WHERE status IN ('running', 'pending')", + ); + let reconciled = match pool { + DbPool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(backups_sql.as_str())) + .bind(now.to_string()) + .execute(p) + .await + .map_err(dberr)? + .rows_affected(), + DbPool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(backups_sql.as_str())) + .bind(now.to_string()) + .execute(p) + .await + .map_err(dberr)? + .rows_affected(), + DbPool::MySql(p) => sqlx::query(sqlx::AssertSqlSafe(backups_sql.as_str())) + .bind(now.to_string()) + .execute(p) + .await + .map_err(dberr)? + .rows_affected(), + }; + + exec!( + pool, + "UPDATE restore_jobs SET status = 'failed', error = 'interrupted: server stopped during restore', \ + completed_at = ? WHERE status IN ('running', 'pending')", + now.to_string(), + ); + + Ok(reconciled) +} + pub async fn get_backup(pool: &DbPool, id: &str) -> Result, BackupError> { let sql = format!("SELECT {BACKUP_COLS} FROM backups WHERE id = ?"); Ok(fetch_opt_as!(pool, BackupRow, &sql, id.to_string())) From 53d92666ff6ad986d2322cd5cb87f7dd195bc919 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:07:03 +0530 Subject: [PATCH 07/66] feat(backend): print search reindex hint after CLI restore --- apps/backend/src/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 511824e4..fc39d7fd 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -443,6 +443,14 @@ async fn run_restore( }) .await?; println!("Restore complete."); + let reindex_path = match (scope, site) { + ("site", Some(sid)) => format!("/api/dashboard/sites/{sid}/search/reindex"), + _ => "/api/dashboard/instance/search/reindex".to_string(), + }; + println!( + "Note: the full-text search index may now be stale. Rebuild it from the dashboard \ + (Settings → Backups → Rebuild search index) or POST {reindex_path} once the server is running." + ); Ok(()) } From 6aa827fa925342d76cb800fe2a5523372f16c2f9 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:07:08 +0530 Subject: [PATCH 08/66] feat(dashboard): add search reindex button to backups section --- .../components/backups/backups-section.tsx | 52 ++++++++++++++++++- apps/dashboard/src/lib/api.ts | 8 +++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/components/backups/backups-section.tsx b/apps/dashboard/src/components/backups/backups-section.tsx index 86a406f4..3452e4c4 100644 --- a/apps/dashboard/src/components/backups/backups-section.tsx +++ b/apps/dashboard/src/components/backups/backups-section.tsx @@ -6,6 +6,7 @@ import { Lock, Play, Plus, + RefreshCw, RotateCcw, Trash2, } from "lucide-react"; @@ -64,6 +65,7 @@ import { inspectBackupUpload, listBackupSchedules, listBackups, + reindexSearch, restoreBackup, restoreBackupUpload, runBackupSchedule, @@ -128,6 +130,15 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { const backupsQuery = useQuery({ queryKey: ["backups", scopeKey], queryFn: () => listBackups(scope), + // Poll while a backup is in flight so the list (incl. scheduled backups the UI + // never hears about) and status transitions appear without a manual refresh. + refetchInterval: (query) => + query.state.data?.some( + (b) => b.status === "running" || b.status === "pending", + ) + ? 2500 + : false, + refetchOnWindowFocus: true, }); const schedulesQuery = useQuery({ queryKey: ["backup-schedules", scopeKey], @@ -158,6 +169,14 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { onError: (e: Error) => toast.error(e.message), }); + const reindexMutation = useMutation({ + mutationFn: () => reindexSearch(scope), + onSuccess: (res) => { + toast.success(`Search index rebuilt — ${res.reindexed} entries`); + }, + onError: (e: Error) => toast.error(e.message), + }); + const restoreMutation = useMutation({ mutationFn: async () => { if (!restoreSource) return; @@ -432,6 +451,28 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { }} /> + {/* Search index */} + + + Search index + + The full-text search index is derived from your content and rebuilt + automatically. Rebuild it manually after a CLI restore, or if search + results look stale. + + + + + + + {/* Restore confirmation */}
- ({ + value: p.value, + label: p.label, + }))} + value={preset} + onValueChange={(v) => setPreset(v ?? "")} + > + diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 017356c9..99e3853a 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -750,6 +750,14 @@ export async function runBackupSchedule(scope: BackupScope, id: string) { ); } +/** Rebuild the full-text search index for the given scope (owner/operator). */ +export async function reindexSearch(scope: BackupScope) { + return api<{ reindexed: number }>( + `${backupScopePrefix(scope)}/search/reindex`, + { method: "POST" }, + ); +} + // --- Sites API --- export async function getSites() { From 371c7c69045efa673a6a4964ebd50685ccbf32a6 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:07:14 +0530 Subject: [PATCH 09/66] fix(dashboard): exclude singletons from dashboard content stats --- .../src/routes/_admin/sites.$siteId/index.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/routes/_admin/sites.$siteId/index.tsx b/apps/dashboard/src/routes/_admin/sites.$siteId/index.tsx index 99cfbb89..c9b6d173 100644 --- a/apps/dashboard/src/routes/_admin/sites.$siteId/index.tsx +++ b/apps/dashboard/src/routes/_admin/sites.$siteId/index.tsx @@ -56,10 +56,17 @@ function DashboardPage() { const singletons = collectionsArray.filter((c) => c.is_singleton); const allEntriesArray = entriesResponse?.items ?? []; - const publishedCount = allEntriesArray.filter( + // Singletons have no publish lifecycle — their backing entry just carries the + // default "draft" status — so exclude them from content stats and the + // recently-updated list. + const singletonIds = new Set(singletons.map((c) => c.id)); + const contentEntries = allEntriesArray.filter( + (e: Entry) => !singletonIds.has(e.collection_id), + ); + const publishedCount = contentEntries.filter( (e: Entry) => e.status === "published", ).length; - const draftCount = allEntriesArray.filter( + const draftCount = contentEntries.filter( (e: Entry) => e.status === "draft", ).length; @@ -189,11 +196,11 @@ function DashboardPage() { )} {/* Recently updated */} - {allEntriesArray.length > 0 && ( + {contentEntries.length > 0 && (

Recently updated

- {allEntriesArray.slice(0, 5).map((item: Entry) => { + {contentEntries.slice(0, 5).map((item: Entry) => { const collectionName = collectionsArray.find( (c: Collection) => c.id === item.collection_id, )?.name; From 87522119b46cf7b161e8222c196a294cdd234166 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:07:20 +0530 Subject: [PATCH 10/66] fix(dashboard): add required items prop to Select components --- .../src/components/site-settings/api-keys-section.tsx | 4 ++++ .../src/components/site-settings/members-section.tsx | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/apps/dashboard/src/components/site-settings/api-keys-section.tsx b/apps/dashboard/src/components/site-settings/api-keys-section.tsx index 185e1d70..62598c36 100644 --- a/apps/dashboard/src/components/site-settings/api-keys-section.tsx +++ b/apps/dashboard/src/components/site-settings/api-keys-section.tsx @@ -221,6 +221,10 @@ export function ApiKeysSection({ siteId }: { siteId: string }) { Permissions setRole(value as "editor" | "viewer")} > @@ -205,6 +211,7 @@ export function MembersSection({ {canManage ? ( `. base-ui's `` + * renders the trigger label from this map, so the dropdowns must pass it. + */ +export const INSTANCE_ROLE_ITEMS: { value: RoleValue; label: string }[] = [ + { value: ROLE_USER, label: "User" }, + { value: "instance_admin", label: "Instance admin" }, + { value: "instance_owner", label: "Instance owner" }, +]; + /** True for instance operators (owner or admin) who manage the whole installation. */ export function isOperator(role: InstanceRole | null | undefined): boolean { return role === "instance_owner" || role === "instance_admin"; @@ -142,7 +156,7 @@ export interface SiteMember { id: string; site_id: string; user_id: string; - username: string; + name: string; email: string; role: "editor" | "viewer"; created_at: string; @@ -440,21 +454,21 @@ export async function getWebhookDeliveries( // --- Auth API --- -export async function login(username: string, password: string) { +export async function login(email: string, password: string) { return authApi("/login", { method: "POST", - body: JSON.stringify({ username, password }), + body: JSON.stringify({ email, password }), }); } export async function register( - username: string, + name: string, email: string, password: string, ) { return authApi("/register", { method: "POST", - body: JSON.stringify({ username, email, password }), + body: JSON.stringify({ name, email, password }), }); } @@ -492,7 +506,7 @@ export async function getInstanceUsers() { } export async function createManagedUser(data: { - username: string; + name: string; email: string; temporary_password: string; instance_role: InstanceRole | null; @@ -513,6 +527,37 @@ export async function updateInstanceRole( }); } +export async function updateUser( + userId: string, + data: { name: string; email: string }, +) { + return api(`/instance/users/${userId}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +export async function deleteUser(userId: string) { + return api(`/instance/users/${userId}`, { method: "DELETE" }); +} + +export async function adminSetUserPassword( + userId: string, + newPassword: string, +) { + return api(`/instance/users/${userId}/password`, { + method: "POST", + body: JSON.stringify({ new_password: newPassword }), + }); +} + +export async function updateMyProfile(data: { name: string }) { + return authApi("/me", { + method: "PUT", + body: JSON.stringify(data), + }); +} + // --- Backup & Restore API --- export type BackupScope = @@ -795,7 +840,7 @@ export async function getSiteMembers(id: string) { export async function inviteMember( siteId: string, - data: { username: string; role: string }, + data: { email: string; role: string }, ) { return api(`/sites/${siteId}/members`, { method: "POST", From 1078e4d815e620ca973704e330cc6a4fa61a024f Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:38:06 +0530 Subject: [PATCH 46/66] fix(dashboard): keep me query alive during login/logout to prevent orphaned observer - RemoveQueries for all except ['me'] to avoid stale cached data - Refetch ['me'] in place so AuthProvider's observer re-renders --- apps/dashboard/src/contexts/auth-context.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/contexts/auth-context.tsx b/apps/dashboard/src/contexts/auth-context.tsx index c21482f7..8e808708 100644 --- a/apps/dashboard/src/contexts/auth-context.tsx +++ b/apps/dashboard/src/contexts/auth-context.tsx @@ -23,8 +23,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); const handleLogin = async () => { - // After successful login API call elsewhere - await queryClient.invalidateQueries({ queryKey: ["me"] }); + // A previous user's data may still be cached. Drop every query EXCEPT + // ["me"]: that query is watched by this provider's always-mounted observer, + // and removing it (clear/removeQueries) orphans the observer — it never + // refetches, so the UI keeps showing the previous user until a hard reload. + // Instead refetch ["me"] in place so the provider re-renders with the + // freshly signed-in user. + queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" }); + await queryClient.refetchQueries({ queryKey: ["me"] }); }; const handleLogout = async () => { @@ -34,7 +40,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { // ignore errors (expired session etc.) } - queryClient.removeQueries({ queryKey: ["me"] }); + // Same reasoning as handleLogin: purge all other user-scoped queries + // (sites/sessions/collections/files…) but keep + refetch ["me"] so the + // observer stays attached. The refetch hits the now-invalid session → + // 401 → user becomes null. + queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" }); + await queryClient.refetchQueries({ queryKey: ["me"] }); }; return ( From 3a7095a8b70159b7087e15428aff1152ba105fc1 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:38:11 +0530 Subject: [PATCH 47/66] feat(dashboard): update components for name renames, site deletion, RBAC gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserAvatar: username prop → name - create-user-dialog: username → name, use INSTANCE_ROLE_ITEMS - site-switcher: gate 'Add site' behind isOperator check - general-section: add site deletion with confirmation dialog - members-section: invite by email, display name instead of username - user-combobox: show name instead of username --- .../src/components/dashboard-header.tsx | 6 +- .../instance/create-user-dialog.tsx | 23 +-- .../src/components/sidebar/app-sidebar.tsx | 2 +- .../src/components/sidebar/nav-user.tsx | 4 +- .../src/components/sidebar/site-switcher.tsx | 39 ++-- .../site-settings/general-section.tsx | 184 ++++++++++++------ .../site-settings/members-section.tsx | 8 +- .../site-settings/user-combobox.tsx | 6 +- apps/dashboard/src/components/user-avatar.tsx | 8 +- 9 files changed, 182 insertions(+), 98 deletions(-) diff --git a/apps/dashboard/src/components/dashboard-header.tsx b/apps/dashboard/src/components/dashboard-header.tsx index 35c6f670..8eee43ac 100644 --- a/apps/dashboard/src/components/dashboard-header.tsx +++ b/apps/dashboard/src/components/dashboard-header.tsx @@ -19,7 +19,7 @@ export function DashboardHeader() { const auth = useAuth(); const navigate = useNavigate(); const showInstanceSettings = isOperator(auth.user?.instance_role); - const name = auth.user?.username ?? "User"; + const name = auth.user?.name ?? "User"; const email = auth.user?.email ?? ""; const handleLogout = async () => { @@ -61,13 +61,13 @@ export function DashboardHeader() { /> } > - +
- +
{name} diff --git a/apps/dashboard/src/components/instance/create-user-dialog.tsx b/apps/dashboard/src/components/instance/create-user-dialog.tsx index 176b6e1f..0ffe62fe 100644 --- a/apps/dashboard/src/components/instance/create-user-dialog.tsx +++ b/apps/dashboard/src/components/instance/create-user-dialog.tsx @@ -22,15 +22,14 @@ import { } from "@/components/ui/select"; import { createManagedUser, - type InstanceRole, + INSTANCE_ROLE_ITEMS, + ROLE_USER, + type RoleValue, type UserPublic, } from "@/lib/api"; -const ROLE_USER = "user"; -type RoleValue = InstanceRole | typeof ROLE_USER; - const EMPTY = { - username: "", + name: "", email: "", temporary_password: "", role: ROLE_USER as RoleValue, @@ -56,7 +55,7 @@ export function CreateUserDialog({ const createMutation = useMutation({ mutationFn: () => createManagedUser({ - username: form.username, + name: form.name, email: form.email, temporary_password: form.temporary_password, instance_role: form.role === ROLE_USER ? null : form.role, @@ -90,16 +89,17 @@ export function CreateUserDialog({ > - Username + Name setForm((current) => ({ ...current, - username: event.target.value, + name: event.target.value, })) } /> @@ -140,6 +140,7 @@ export function CreateUserDialog({ Access field.handleChange(e.target.value)} - className="max-w-md" - aria-invalid={isInvalid} - disabled={!canManage} - /> - {isInvalid && ( - - )} - - ); - }} - /> - - - - - - + + + General + + Basic information about this site. + + + + + { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Site Name + field.handleChange(e.target.value)} + className="max-w-md" + aria-invalid={isInvalid} + disabled={!canManage} + /> + {isInvalid && ( + + )} + + ); + }} + /> + + + + + + + + {canManage && ( + + + Danger zone + + Deleting a site permanently removes its content, files, schema, + and members. This cannot be undone. + + + + + + + )} + + + + + Delete site + + Permanently delete {site.name} and all of its + content, files, and members. This cannot be undone. + + + + }> + Cancel + + + + + +
); } diff --git a/apps/dashboard/src/components/site-settings/members-section.tsx b/apps/dashboard/src/components/site-settings/members-section.tsx index d54e2aa4..5a5758cc 100644 --- a/apps/dashboard/src/components/site-settings/members-section.tsx +++ b/apps/dashboard/src/components/site-settings/members-section.tsx @@ -75,7 +75,7 @@ export function MembersSection({ queryClient.invalidateQueries({ queryKey: ["site-members", siteId] }); const inviteMutation = useMutation({ - mutationFn: (username: string) => inviteMember(siteId, { username, role }), + mutationFn: (email: string) => inviteMember(siteId, { email, role }), onSuccess: () => { invalidate(); setSelectedUserId(null); @@ -105,7 +105,7 @@ export function MembersSection({ const user = candidates.find( (candidate) => candidate.id === selectedUserId, ); - if (user) inviteMutation.mutate(user.username); + if (user) inviteMutation.mutate(user.email); }; return ( @@ -189,7 +189,7 @@ export function MembersSection({ {operators.map((user) => ( -
{user.username}
+
{user.name}
{user.email}
@@ -203,7 +203,7 @@ export function MembersSection({ {members?.map((member) => ( -
{member.username}
+
{member.name}
{member.email}
diff --git a/apps/dashboard/src/components/site-settings/user-combobox.tsx b/apps/dashboard/src/components/site-settings/user-combobox.tsx index ae7358e5..29978c2f 100644 --- a/apps/dashboard/src/components/site-settings/user-combobox.tsx +++ b/apps/dashboard/src/components/site-settings/user-combobox.tsx @@ -51,7 +51,7 @@ export function UserCombobox({ - {selected ? selected.username : placeholder} + {selected ? selected.name : placeholder} @@ -70,7 +70,7 @@ export function UserCombobox({ {users.map((user) => ( { onChange(user.id); setOpen(false); @@ -84,7 +84,7 @@ export function UserCombobox({ />
- {user.username} + {user.name} {user.email} diff --git a/apps/dashboard/src/components/user-avatar.tsx b/apps/dashboard/src/components/user-avatar.tsx index a2dd9397..ee9dad7a 100644 --- a/apps/dashboard/src/components/user-avatar.tsx +++ b/apps/dashboard/src/components/user-avatar.tsx @@ -4,21 +4,21 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; type UserAvatarProps = { - username: string; + name: string; image?: string | null; className?: string | undefined; }; -export function UserAvatar({ username, image, className }: UserAvatarProps) { +export function UserAvatar({ name, image, className }: UserAvatarProps) { return ( - + From edd7f97e4a23c71ff8d9367df6b24bfbce5640c0 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:38:17 +0530 Subject: [PATCH 48/66] feat(dashboard): update route pages for email login, name renames, user management UI - login: email field instead of username, async auth.login() - register: name field instead of username, async auth.login() - account: add profile section (edit display name), fix session revocation - settings/users: add edit/delete/reset-password dialogs, role dropdown items - settings/index: display name instead of username --- .../src/routes/_admin/_shell/account.tsx | 85 ++++- .../routes/_admin/_shell/settings/index.tsx | 2 +- .../routes/_admin/_shell/settings/users.tsx | 326 ++++++++++++++++-- apps/dashboard/src/routes/login.tsx | 24 +- apps/dashboard/src/routes/register.tsx | 25 +- 5 files changed, 404 insertions(+), 58 deletions(-) diff --git a/apps/dashboard/src/routes/_admin/_shell/account.tsx b/apps/dashboard/src/routes/_admin/_shell/account.tsx index 5b5d0332..4d502c61 100644 --- a/apps/dashboard/src/routes/_admin/_shell/account.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/account.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { KeyRound, LogOut } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -16,7 +16,12 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/contexts/auth-context"; -import { changePassword, getSessions, revokeAllSessions } from "@/lib/api"; +import { + changePassword, + getSessions, + revokeAllSessions, + updateMyProfile, +} from "@/lib/api"; export const Route = createFileRoute("/_admin/_shell/account")({ component: AccountPage, @@ -28,16 +33,35 @@ function AccountPage() { const queryClient = useQueryClient(); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); + const [displayName, setDisplayName] = useState(auth.user?.name ?? ""); const { data: sessions, isLoading } = useQuery({ queryKey: ["sessions"], queryFn: getSessions, }); + // Sync the field once the signed-in user loads (auth.user may be null on first render). + const loadedName = auth.user?.name; + useEffect(() => { + if (loadedName) setDisplayName(loadedName); + }, [loadedName]); + + const profileMutation = useMutation({ + mutationFn: () => updateMyProfile({ name: displayName }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["me"] }); + toast.success("Profile updated"); + }, + onError: (error: Error) => toast.error(error.message), + }); + const passwordMutation = useMutation({ mutationFn: () => changePassword(currentPassword, newPassword), onSuccess: async () => { toast.success("Password changed. Sign in again."); - queryClient.clear(); + // Keep the ["me"] query alive (clearing it orphans AuthProvider's + // observer); refetch it instead → 401 → signed-out state. + queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" }); + await queryClient.refetchQueries({ queryKey: ["me"] }); navigate({ to: "/login" }); }, onError: (error: Error) => toast.error(error.message), @@ -45,9 +69,12 @@ function AccountPage() { const revokeMutation = useMutation({ mutationFn: revokeAllSessions, - onSuccess: () => { + onSuccess: async () => { toast.success("All sessions revoked"); - queryClient.clear(); + // Keep the ["me"] query alive (clearing it orphans AuthProvider's + // observer); refetch it instead → 401 → signed-out state. + queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" }); + await queryClient.refetchQueries({ queryKey: ["me"] }); navigate({ to: "/login" }); }, onError: (error: Error) => toast.error(error.message), @@ -78,6 +105,54 @@ function AccountPage() { ) : null} + + + Profile + + Your display name is shown across the dashboard. You sign in with + your email. + + + +
{ + event.preventDefault(); + profileMutation.mutate(); + }} + > + + + Name + setDisplayName(event.target.value)} + placeholder="John Doe" + required + /> + + {auth.user?.email ? ( + + Email + + + ) : null} + + +
+
+
+ Change password diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx index c791657b..98c11461 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx @@ -35,7 +35,7 @@ function GeneralInstanceSettings() { - + diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx index 32b7a65e..3aefd134 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { UserPlus } from "lucide-react"; +import { MoreHorizontal, UserPlus } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { CreateUserDialog } from "@/components/instance/create-user-dialog"; import { Badge } from "@/components/ui/badge"; @@ -13,6 +14,24 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -22,21 +41,25 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { + adminSetUserPassword, + deleteUser, getInstanceUsers, getMe, + INSTANCE_ROLE_ITEMS, type InstanceRole, instanceRoleLabel, isOperator, + ROLE_USER, + type RoleValue, updateInstanceRole, + updateUser, + type UserPublic, } from "@/lib/api"; export const Route = createFileRoute("/_admin/_shell/settings/users")({ component: InstanceUsers, }); -const ROLE_USER = "user"; -type RoleValue = InstanceRole | typeof ROLE_USER; - function toRole(value: RoleValue): InstanceRole | null { return value === ROLE_USER ? null : value; } @@ -51,6 +74,13 @@ function InstanceUsers() { queryFn: getInstanceUsers, }); + const [editUser, setEditUser] = useState(null); + const [passwordUser, setPasswordUser] = useState(null); + const [removeUser, setRemoveUser] = useState(null); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["instance-users"] }); + const roleMutation = useMutation({ mutationFn: ({ userId, @@ -60,12 +90,22 @@ function InstanceUsers() { role: InstanceRole | null; }) => updateInstanceRole(userId, role), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["instance-users"] }); + invalidate(); toast.success("Instance role updated"); }, onError: (error: Error) => toast.error(error.message), }); + const removeMutation = useMutation({ + mutationFn: (userId: string) => deleteUser(userId), + onSuccess: () => { + invalidate(); + setRemoveUser(null); + toast.success("User deleted"); + }, + onError: (error: Error) => toast.error(error.message), + }); + return (
@@ -108,36 +148,23 @@ function InstanceUsers() { {users?.map((user) => { const isOwner = user.instance_role === "instance_owner"; - const targetIsOperator = - isOwner || user.instance_role === "instance_admin"; - // Operators may change roles; only owners may touch an owner. - const canEditRole = + const isSelf = user.id === me?.id; + // Operators may manage users; only owners may touch an owner. + const canManage = currentIsOperator && (currentIsOwner || !isOwner); + const canEditRole = canManage && !isSelf; return ( -
{user.username}
+
{user.name}
{user.email}
- - {instanceRoleLabel(user.instance_role)} - - - - {user.must_change_password ? ( - Change required - ) : ( - "Configured" - )} - - - {canEditRole && user.id !== me?.id ? ( + {canEditRole ? ( + ) : ( + + {instanceRoleLabel(user.instance_role)} + + )} + + + {user.must_change_password ? ( + Change required + ) : ( + "Configured" + )} + + + {canManage ? ( + + + } + > + + Manage user + + + setEditUser(user)} + > + Edit details + + setPasswordUser(user)} + > + Reset password + + {!isSelf && ( + <> + + setRemoveUser(user)} + > + Delete user + + + )} + + ) : ( )} @@ -174,6 +253,201 @@ function InstanceUsers() { )}
+ + !open && setEditUser(null)} + onSaved={invalidate} + /> + !open && setPasswordUser(null)} + /> + !open && setRemoveUser(null)} + > + + + Delete user + + Permanently delete {removeUser?.name} ( + {removeUser?.email}). This cannot be undone. + + + + }> + Cancel + + + + +
); } + +function EditUserDialog({ + user, + onOpenChange, + onSaved, +}: { + user: UserPublic | null; + onOpenChange: (open: boolean) => void; + onSaved: () => void; +}) { + const [name, setUsername] = useState(""); + const [email, setEmail] = useState(""); + + const mutation = useMutation({ + mutationFn: () => { + if (!user) throw new Error("No user selected"); + return updateUser(user.id, { name, email }); + }, + onSuccess: () => { + onSaved(); + onOpenChange(false); + toast.success("User updated"); + }, + onError: (error: Error) => toast.error(error.message), + }); + + return ( + { + if (open && user) { + setUsername(user.name); + setEmail(user.email); + } + onOpenChange(open); + }} + > + + + Edit user + + Update this user's display name and email. + + +
{ + event.preventDefault(); + mutation.mutate(); + }} + > + + + Name + setUsername(event.target.value)} + /> + + + Email + setEmail(event.target.value)} + /> + + + + }> + Cancel + + + +
+
+
+ ); +} + +function ResetPasswordDialog({ + user, + onOpenChange, +}: { + user: UserPublic | null; + onOpenChange: (open: boolean) => void; +}) { + const [password, setPassword] = useState(""); + + const mutation = useMutation({ + mutationFn: () => { + if (!user) throw new Error("No user selected"); + return adminSetUserPassword(user.id, password); + }, + onSuccess: () => { + onOpenChange(false); + toast.success("Password reset. The user must change it on next sign in."); + }, + onError: (error: Error) => toast.error(error.message), + }); + + return ( + { + if (open) setPassword(""); + onOpenChange(open); + }} + > + + + Reset password + + Set a temporary password for {user?.name}. They + must change it after signing in. + + +
{ + event.preventDefault(); + mutation.mutate(); + }} + > + + + + Temporary password + + setPassword(event.target.value)} + /> + + + + }> + Cancel + + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/routes/login.tsx b/apps/dashboard/src/routes/login.tsx index ac9de58b..c923331a 100644 --- a/apps/dashboard/src/routes/login.tsx +++ b/apps/dashboard/src/routes/login.tsx @@ -22,7 +22,7 @@ import { useAuth } from "@/contexts/auth-context"; import { login as apiLogin } from "@/lib/api"; const loginSchema = z.object({ - username: z.string().min(1, "Username is required"), + email: z.string().email("Enter a valid email address"), password: z.string().min(1, "Password is required"), }); @@ -35,15 +35,10 @@ function LoginPage() { const auth = useAuth(); const loginMutation = useMutation({ - mutationFn: ({ - username, - password, - }: { - username: string; - password: string; - }) => apiLogin(username, password), - onSuccess: () => { - auth.login(); + mutationFn: ({ email, password }: { email: string; password: string }) => + apiLogin(email, password), + onSuccess: async () => { + await auth.login(); toast.success("Logged in!"); navigate({ to: "/" }); }, @@ -54,7 +49,7 @@ function LoginPage() { const form = useForm({ defaultValues: { - username: "", + email: "", password: "", }, validators: { @@ -82,22 +77,23 @@ function LoginPage() { > { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( - Username + Email field.handleChange(e.target.value)} disabled={loginMutation.isPending} aria-invalid={isInvalid} - autoComplete="username" + autoComplete="email" /> {isInvalid && ( diff --git a/apps/dashboard/src/routes/register.tsx b/apps/dashboard/src/routes/register.tsx index 66c26dc3..f560a9a6 100644 --- a/apps/dashboard/src/routes/register.tsx +++ b/apps/dashboard/src/routes/register.tsx @@ -22,10 +22,10 @@ import { useAuth } from "@/contexts/auth-context"; import { register as apiRegister } from "@/lib/api"; const registerSchema = z.object({ - username: z + name: z .string() - .min(3, "Username must be at least 3 characters") - .max(32, "Username must be at most 32 characters"), + .min(1, "Name is required") + .max(64, "Name must be at most 64 characters"), email: z.string().email("Enter a valid email address"), password: z.string().min(8, "Password must be at least 8 characters"), }); @@ -40,16 +40,16 @@ function RegisterPage() { const registerMutation = useMutation({ mutationFn: ({ - username, + name, email, password, }: { - username: string; + name: string; email: string; password: string; - }) => apiRegister(username, email, password), - onSuccess: () => { - auth.login(); + }) => apiRegister(name, email, password), + onSuccess: async () => { + await auth.login(); toast.success("Account created!"); navigate({ to: "/" }); }, @@ -60,7 +60,7 @@ function RegisterPage() { const form = useForm({ defaultValues: { - username: "", + name: "", email: "", password: "", }, @@ -89,22 +89,23 @@ function RegisterPage() { > { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( - Username + Name field.handleChange(e.target.value)} disabled={registerMutation.isPending} aria-invalid={isInvalid} - autoComplete="username" + autoComplete="name" /> {isInvalid && ( From d4d399756f13e465e2f3bc5449519a2451a22fda Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:38:22 +0530 Subject: [PATCH 49/66] =?UTF-8?q?docs:=20update=20docs=20for=20username?= =?UTF-8?q?=E2=86=92name,=20email=20login,=20and=20new=20user=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 ++++++---- CLAUDE.md | 10 ++++++---- README.md | 5 +++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b985d10d..579660c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ vcms serve # run the server vcms config init [--force] [--path P] # write a default config.toml (non-secrets only) vcms config show # print effective merged config (secrets redacted) vcms config path # print resolved config file + search order -vcms admin reset-password --username U --password P +vcms admin reset-password --name U --password P vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] vcms backup list # list recorded backups vcms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes @@ -92,7 +92,7 @@ Layers merge with precedence: **CLI flag > env var > config file > built-in defa Config file search order (first existing wins; missing is fine): 1. `--config` flag / `VCMS_CONFIG` env -2. `./cms.toml` (current dir) +2. `./vcms.toml` (current dir) 3. `~/.vcms/config.toml` (CMS home; `$VCMS_HOME/config.toml` if set) — where `vcms config init` writes 4. `/etc/vcms/config.toml` @@ -199,9 +199,11 @@ Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→ ## First-Run Behavior On initial startup, the server seeds a default admin user (the first user created -is automatically granted the `instance_owner` role): -- Username: `admin` +is automatically granted the `instance_owner` role). **Login is by email**; the +`name` field is a display name (non-unique, e.g. "John Doe"): +- Email: `admin@cms.local` - Password: `admin` +- Display name: `admin` **Change this password immediately in production.** ## Authorization (RBAC) diff --git a/CLAUDE.md b/CLAUDE.md index 2c775f0f..6841d955 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ vcms serve # run the server vcms config init [--force] [--path P] # write a default config.toml (non-secrets only) vcms config show # print effective merged config (secrets redacted) vcms config path # print resolved config file + search order -vcms admin reset-password --username U --password P +vcms admin reset-password --name U --password P vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] vcms backup list # list recorded backups vcms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes @@ -93,7 +93,7 @@ Layers merge with precedence: **CLI flag > env var > config file > built-in defa Config file search order (first existing wins; missing is fine): 1. `--config` flag / `VCMS_CONFIG` env -2. `./cms.toml` (current dir) +2. `./vcms.toml` (current dir) 3. `~/.vcms/config.toml` (CMS home; `$VCMS_HOME/config.toml` if set) — where `vcms config init` writes 4. `/etc/vcms/config.toml` @@ -204,9 +204,11 @@ Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→ ## First-Run Behavior On initial startup, the server seeds a default admin user (the first user created -is automatically granted the `instance_owner` role): -- Username: `admin` +is automatically granted the `instance_owner` role). **Login is by email**; the +`name` field is a display name (non-unique, e.g. "John Doe"): +- Email: `admin@cms.local` - Password: `admin` +- Display name: `admin` **Change this password immediately in production.** ## Authorization (RBAC) diff --git a/README.md b/README.md index 3ae2bb2a..787ba763 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,11 @@ bun run build ``` Visit `http://localhost:3000` and log in with: -- **Username:** `admin` +- **Email:** `admin@cms.local` - **Password:** `admin` -*Change the default password after your first login.* +*Login is by email; the name field is just a display name. Change the default +password after your first login.* ### Access Your Content From 628811dbf9b7e79c3b7c861958d00f58eff9027b Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:41:21 +0530 Subject: [PATCH 50/66] style: formatting --- apps/dashboard/src/components/sidebar/site-switcher.tsx | 4 +--- .../src/components/site-settings/user-combobox.tsx | 4 +--- apps/dashboard/src/lib/api.ts | 6 +----- apps/dashboard/src/routes/_admin/_shell/settings/users.tsx | 2 +- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/dashboard/src/components/sidebar/site-switcher.tsx b/apps/dashboard/src/components/sidebar/site-switcher.tsx index 5cb82cef..dbf50cf3 100644 --- a/apps/dashboard/src/components/sidebar/site-switcher.tsx +++ b/apps/dashboard/src/components/sidebar/site-switcher.tsx @@ -3,9 +3,6 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { ChevronsUpDown, LayoutDashboard, Plus } from "lucide-react"; import type * as React from "react"; import { useState } from "react"; - -import { getMe, isOperator } from "@/lib/api"; - import { DropdownMenu, DropdownMenuContent, @@ -22,6 +19,7 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { Skeleton } from "@/components/ui/skeleton"; +import { getMe, isOperator } from "@/lib/api"; import { SiteAvatar } from "../site-avatar"; interface Site { diff --git a/apps/dashboard/src/components/site-settings/user-combobox.tsx b/apps/dashboard/src/components/site-settings/user-combobox.tsx index 29978c2f..2a6b5841 100644 --- a/apps/dashboard/src/components/site-settings/user-combobox.tsx +++ b/apps/dashboard/src/components/site-settings/user-combobox.tsx @@ -83,9 +83,7 @@ export function UserCombobox({ )} />
- - {user.name} - + {user.name} {user.email} diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 7c4e469f..b2613122 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -461,11 +461,7 @@ export async function login(email: string, password: string) { }); } -export async function register( - name: string, - email: string, - password: string, -) { +export async function register(name: string, email: string, password: string) { return authApi("/register", { method: "POST", body: JSON.stringify({ name, email, password }), diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx index 3aefd134..17405361 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx @@ -51,9 +51,9 @@ import { isOperator, ROLE_USER, type RoleValue, + type UserPublic, updateInstanceRole, updateUser, - type UserPublic, } from "@/lib/api"; export const Route = createFileRoute("/_admin/_shell/settings/users")({ From 602264f01248cfeb8737c8eb10532cdd9fdf754c Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:02:22 +0530 Subject: [PATCH 51/66] fix(tests): update error message assertion for access token requirements --- apps/backend/tests/mcp/protocol_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/tests/mcp/protocol_tests.rs b/apps/backend/tests/mcp/protocol_tests.rs index 12501b38..adbf018e 100644 --- a/apps/backend/tests/mcp/protocol_tests.rs +++ b/apps/backend/tests/mcp/protocol_tests.rs @@ -190,7 +190,7 @@ async fn test_auth_wrong_token_type_returns_401() { ); let msg = error["message"].as_str().unwrap(); assert!( - msg.contains("MCP requires a CMS access token"), + msg.contains("MCP requires a vcms_site_* access token"), "Expected auth error message, got: {}", msg ); @@ -228,7 +228,7 @@ async fn test_auth_instance_token_rejected() { assert!(error.is_some(), "Instance token should be rejected, got: {}", resp); let msg = error.unwrap()["message"].as_str().unwrap(); assert!( - msg.contains("MCP requires a CMS access token"), + msg.contains("MCP requires a vcms_site_* access token"), "Expected MCP token error, got: {}", msg ); From fc8b97f775632965953eb0a7d9fab9587135e341 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:32:35 +0530 Subject: [PATCH 52/66] docs: update CLI examples from --name to --email and add bun install to README --- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 579660c6..ab222c07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ vcms serve # run the server vcms config init [--force] [--path P] # write a default config.toml (non-secrets only) vcms config show # print effective merged config (secrets redacted) vcms config path # print resolved config file + search order -vcms admin reset-password --name U --password P +vcms admin reset-password --email U --password P vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] vcms backup list # list recorded backups vcms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes diff --git a/CLAUDE.md b/CLAUDE.md index 6841d955..7fa991bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ vcms serve # run the server vcms config init [--force] [--path P] # write a default config.toml (non-secrets only) vcms config show # print effective merged config (secrets redacted) vcms config path # print resolved config file + search order -vcms admin reset-password --name U --password P +vcms admin reset-password --email U --password P vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] vcms backup list # list recorded backups vcms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes diff --git a/README.md b/README.md index 787ba763..7919d30b 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ bun run build ./target/release/vcms ``` -Visit `http://localhost:3000` and log in with: +Visit `http://localhost:3000/dashboard` and log in with: - **Email:** `admin@cms.local` - **Password:** `admin` @@ -168,6 +168,7 @@ git clone https://github.com/velopulent/cms ```bash # Run development server cd cms +bun install bun run dev ``` From 4db68e731200811d60b71dd4ac7515b209d0ac3c Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:32:44 +0530 Subject: [PATCH 53/66] fix(migrations): look up seed admin by email instead of name --- apps/backend/migrations/mysql/20260610000000_auth_rbac.sql | 2 +- apps/backend/migrations/postgres/20260610000000_auth_rbac.sql | 2 +- apps/backend/migrations/sqlite/20260610000000_auth_rbac.sql | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/migrations/mysql/20260610000000_auth_rbac.sql b/apps/backend/migrations/mysql/20260610000000_auth_rbac.sql index 38c2870f..9f78e1c7 100644 --- a/apps/backend/migrations/mysql/20260610000000_auth_rbac.sql +++ b/apps/backend/migrations/mysql/20260610000000_auth_rbac.sql @@ -40,7 +40,7 @@ CREATE INDEX idx_security_audit_created ON security_audit_events(created_at); UPDATE users SET instance_role = 'instance_owner' WHERE id = COALESCE( - (SELECT selected.id FROM (SELECT id FROM users WHERE name = 'admin' ORDER BY created_at LIMIT 1) selected), + (SELECT selected.id FROM (SELECT id FROM users WHERE email = 'admin@cms.local') selected), (SELECT selected.id FROM (SELECT id FROM users ORDER BY created_at, id LIMIT 1) selected) ) AND NOT EXISTS ( diff --git a/apps/backend/migrations/postgres/20260610000000_auth_rbac.sql b/apps/backend/migrations/postgres/20260610000000_auth_rbac.sql index 6969a4b0..04ae9319 100644 --- a/apps/backend/migrations/postgres/20260610000000_auth_rbac.sql +++ b/apps/backend/migrations/postgres/20260610000000_auth_rbac.sql @@ -35,7 +35,7 @@ CREATE INDEX idx_security_audit_created ON security_audit_events(created_at); UPDATE users SET instance_role = 'instance_owner' WHERE id = COALESCE( - (SELECT id FROM users WHERE name = 'admin' ORDER BY created_at LIMIT 1), + (SELECT id FROM users WHERE email = 'admin@cms.local'), (SELECT id FROM users ORDER BY created_at, id LIMIT 1) ) AND NOT EXISTS (SELECT 1 FROM users WHERE instance_role = 'instance_owner'); diff --git a/apps/backend/migrations/sqlite/20260610000000_auth_rbac.sql b/apps/backend/migrations/sqlite/20260610000000_auth_rbac.sql index 25c21ec9..788e977e 100644 --- a/apps/backend/migrations/sqlite/20260610000000_auth_rbac.sql +++ b/apps/backend/migrations/sqlite/20260610000000_auth_rbac.sql @@ -35,7 +35,7 @@ CREATE INDEX idx_security_audit_created ON security_audit_events(created_at); UPDATE users SET instance_role = 'instance_owner' WHERE id = COALESCE( - (SELECT id FROM users WHERE name = 'admin' ORDER BY created_at LIMIT 1), + (SELECT id FROM users WHERE email = 'admin@cms.local'), (SELECT id FROM users ORDER BY created_at, id LIMIT 1) ) AND NOT EXISTS (SELECT 1 FROM users WHERE instance_role = 'instance_owner'); From d997bfa436b685f7e44e22ab13cfffcf9faec660 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:32:49 +0530 Subject: [PATCH 54/66] refactor: remove unused find_id_by_name from UserRepository trait and all backends --- apps/backend/src/repository/mysql/user.rs | 9 --------- apps/backend/src/repository/postgres/user.rs | 9 --------- apps/backend/src/repository/sqlite/user.rs | 9 --------- apps/backend/src/repository/traits.rs | 1 - apps/backend/src/test_helpers.rs | 5 ----- 5 files changed, 33 deletions(-) diff --git a/apps/backend/src/repository/mysql/user.rs b/apps/backend/src/repository/mysql/user.rs index dbabfc2b..17b386e8 100644 --- a/apps/backend/src/repository/mysql/user.rs +++ b/apps/backend/src/repository/mysql/user.rs @@ -48,15 +48,6 @@ impl UserRepository for MysqlUserRepository { ).fetch_all(&self.pool).await?) } - async fn find_id_by_name(&self, name: &str) -> Result, RepositoryError> { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE name = ?") - .bind(name) - .fetch_optional(&self.pool) - .await?; - - Ok(result.map(|(id,)| id)) - } - async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { sqlx::query("INSERT INTO users (id, name, email, password_hash) VALUES (?, ?, ?, ?)") .bind(id) diff --git a/apps/backend/src/repository/postgres/user.rs b/apps/backend/src/repository/postgres/user.rs index 03e6d3e2..71f8ef5e 100644 --- a/apps/backend/src/repository/postgres/user.rs +++ b/apps/backend/src/repository/postgres/user.rs @@ -50,15 +50,6 @@ impl UserRepository for PostgresUserRepository { ).fetch_all(&self.pool).await?) } - async fn find_id_by_name(&self, name: &str) -> Result, RepositoryError> { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE name = $1") - .bind(name) - .fetch_optional(&self.pool) - .await?; - - Ok(result.map(|(id,)| id)) - } - async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { sqlx::query("INSERT INTO users (id, name, email, password_hash) VALUES ($1, $2, $3, $4)") .bind(id) diff --git a/apps/backend/src/repository/sqlite/user.rs b/apps/backend/src/repository/sqlite/user.rs index 5e0fff98..0af0fe8f 100644 --- a/apps/backend/src/repository/sqlite/user.rs +++ b/apps/backend/src/repository/sqlite/user.rs @@ -50,15 +50,6 @@ impl UserRepository for SqliteUserRepository { ).fetch_all(&self.pool).await?) } - async fn find_id_by_name(&self, name: &str) -> Result, RepositoryError> { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE name = ?") - .bind(name) - .fetch_optional(&self.pool) - .await?; - - Ok(result.map(|(id,)| id)) - } - async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { debug!("Creating user"); diff --git a/apps/backend/src/repository/traits.rs b/apps/backend/src/repository/traits.rs index 89a63b1d..44331b31 100644 --- a/apps/backend/src/repository/traits.rs +++ b/apps/backend/src/repository/traits.rs @@ -18,7 +18,6 @@ pub trait UserRepository: Send + Sync { async fn find_by_email(&self, email: &str) -> Result, RepositoryError>; async fn find_by_id(&self, id: &str) -> Result, RepositoryError>; async fn list(&self) -> Result, RepositoryError>; - async fn find_id_by_name(&self, name: &str) -> Result, RepositoryError>; async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError>; async fn exists(&self, name: &str) -> Result; async fn get_role(&self, user_id: &str, site_id: &str) -> Result, RepositoryError>; diff --git a/apps/backend/src/test_helpers.rs b/apps/backend/src/test_helpers.rs index d5ffd535..7e18bdbc 100644 --- a/apps/backend/src/test_helpers.rs +++ b/apps/backend/src/test_helpers.rs @@ -92,11 +92,6 @@ impl UserRepository for InMemoryUserRepository { Ok(self.users.lock().unwrap().clone()) } - async fn find_id_by_name(&self, name: &str) -> Result, RepositoryError> { - let users = self.users.lock().unwrap(); - Ok(users.iter().find(|u| u.name == name).map(|u| u.id.clone())) - } - async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { let mut users = self.users.lock().unwrap(); let mut by_name = self.by_name.lock().unwrap(); From 5bde399f6fe5eee6dbdb1ce125de036c88c286bf Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:32:53 +0530 Subject: [PATCH 55/66] fix(repository): add id to ORDER BY for deterministic site member listing --- apps/backend/src/repository/mysql/site.rs | 2 +- apps/backend/src/repository/postgres/site.rs | 2 +- apps/backend/src/repository/sqlite/site.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/repository/mysql/site.rs b/apps/backend/src/repository/mysql/site.rs index 35bf29e2..61b8bffc 100644 --- a/apps/backend/src/repository/mysql/site.rs +++ b/apps/backend/src/repository/mysql/site.rs @@ -98,7 +98,7 @@ impl SiteRepository for MysqlSiteRepository { FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.site_id = ? - ORDER BY sm.role DESC, u.name", + ORDER BY sm.role DESC, u.name, u.id", ) .bind(site_id) .fetch_all(&self.pool) diff --git a/apps/backend/src/repository/postgres/site.rs b/apps/backend/src/repository/postgres/site.rs index 6c8c2cc2..23c9bf99 100644 --- a/apps/backend/src/repository/postgres/site.rs +++ b/apps/backend/src/repository/postgres/site.rs @@ -98,7 +98,7 @@ impl SiteRepository for PostgresSiteRepository { FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.site_id = $1 - ORDER BY sm.role DESC, u.name", + ORDER BY sm.role DESC, u.name, u.id", ) .bind(site_id) .fetch_all(&self.pool) diff --git a/apps/backend/src/repository/sqlite/site.rs b/apps/backend/src/repository/sqlite/site.rs index a706444e..cfd22b47 100644 --- a/apps/backend/src/repository/sqlite/site.rs +++ b/apps/backend/src/repository/sqlite/site.rs @@ -98,7 +98,7 @@ impl SiteRepository for SqliteSiteRepository { FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.site_id = ? - ORDER BY sm.role DESC, u.name", + ORDER BY sm.role DESC, u.name, u.id", ) .bind(site_id) .fetch_all(&self.pool) From 81069c03fc99d574a598b00e69170f9ecf1d0f7e Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:32:57 +0530 Subject: [PATCH 56/66] fix(database): enable foreign keys pragma in SQLite pool configuration --- apps/backend/src/database/pool.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/database/pool.rs b/apps/backend/src/database/pool.rs index cdd128d3..04c59e27 100644 --- a/apps/backend/src/database/pool.rs +++ b/apps/backend/src/database/pool.rs @@ -57,6 +57,7 @@ impl DbPool { let options = sqlx::sqlite::SqliteConnectOptions::from_str(&config.database_url) .map_err(|e| Error::Configuration(e.to_string().into()))? .create_if_missing(create_sqlite_if_missing) + .foreign_keys(true) .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) .busy_timeout(Duration::from_secs(30)); let pool = SqlitePoolOptions::new() From 5694848eff3d3b725fd249406a8fc83f6d770468 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:33:20 +0530 Subject: [PATCH 57/66] fix(auth): update error messages from 'name' to 'email' references --- apps/backend/src/services/auth.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/services/auth.rs b/apps/backend/src/services/auth.rs index 89eb6737..7e38a787 100644 --- a/apps/backend/src/services/auth.rs +++ b/apps/backend/src/services/auth.rs @@ -72,13 +72,10 @@ impl AuthError { pub fn into_response(self) -> Response { let (status, body) = match self { AuthError::ValidationError(msg) => (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))), - AuthError::UserExists => ( - StatusCode::CONFLICT, - Json(json!({"error": "Username or email already exists"})), - ), + AuthError::UserExists => (StatusCode::CONFLICT, Json(json!({"error": "Email already exists"}))), AuthError::InvalidCredentials => ( StatusCode::UNAUTHORIZED, - Json(json!({"error": "Invalid name or password"})), + Json(json!({"error": "Invalid email or password"})), ), AuthError::RegistrationDisabled => ( StatusCode::FORBIDDEN, @@ -190,6 +187,7 @@ impl AuthService { pub async fn login(&self, email: &str, password: &str) -> Result<(UserPublic, String, String), AuthError> { let email = email.trim(); + let password = password.trim(); debug!("Attempting login for email={}", email); let user = self From eed7474ac534491938423e0d57749c2692025986 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:35:36 +0530 Subject: [PATCH 58/66] fix(auth): trim passwords and normalize instance role before DB write --- apps/backend/src/services/auth.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/services/auth.rs b/apps/backend/src/services/auth.rs index 7e38a787..66946a5a 100644 --- a/apps/backend/src/services/auth.rs +++ b/apps/backend/src/services/auth.rs @@ -284,6 +284,7 @@ impl AuthService { temporary_password: &str, instance_role: Option<&str>, ) -> Result { + let temporary_password = temporary_password.trim(); if temporary_password.len() < 8 { return Err(AuthError::ValidationError( "Temporary password must be at least 8 characters".into(), @@ -296,6 +297,8 @@ impl AuthService { if name.is_empty() || !EMAIL_RE.is_match(email) { return Err(AuthError::ValidationError("Invalid name or email".into())); } + // Validate the role before any DB write so a bad role never leaves an orphan user. + let instance_role = normalize_instance_role(instance_role)?; let id = Uuid::now_v7().to_string(); let password_hash = hash(temporary_password, self.bcrypt_cost).map_err(|e| AuthError::HashError(e.to_string()))?; @@ -306,7 +309,6 @@ impl AuthService { RepositoryError::UniqueViolation(_) => AuthError::UserExists, other => AuthError::DatabaseError(other.to_string()), })?; - let instance_role = normalize_instance_role(instance_role)?; self.user_repo .set_instance_role(&id, instance_role) .await @@ -402,6 +404,7 @@ impl AuthService { /// Operator-driven password reset; forces a change on the user's next login. pub async fn admin_set_password(&self, user_id: &str, new_password: &str) -> Result<(), AuthError> { + let new_password = new_password.trim(); if new_password.len() < 8 { return Err(AuthError::ValidationError( "Password must be at least 8 characters".into(), @@ -554,6 +557,8 @@ impl AuthService { current_password: &str, new_password: &str, ) -> Result<(), AuthError> { + let current_password = current_password.trim(); + let new_password = new_password.trim(); if new_password.len() < 8 { return Err(AuthError::ValidationError( "Password must be at least 8 characters".into(), From 2f7abdc3c81dcf1d688e8d5ca5a3a1da577c8109 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:35:59 +0530 Subject: [PATCH 59/66] fix(cli): rename reset-password --name to --email and look up by email --- apps/backend/src/cli.rs | 6 +++--- apps/backend/src/main.rs | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/cli.rs b/apps/backend/src/cli.rs index b328d6b6..5a0eff60 100644 --- a/apps/backend/src/cli.rs +++ b/apps/backend/src/cli.rs @@ -139,10 +139,10 @@ pub enum ConfigAction { #[derive(Subcommand, Debug)] pub enum AdminAction { - /// Reset a user's password. + /// Reset a user's password. The user is identified by their unique login email. ResetPassword { - #[arg(long, value_name = "NAME")] - name: String, + #[arg(long, value_name = "EMAIL")] + email: String, #[arg(long, value_name = "PASSWORD")] password: String, }, diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index d640ca7e..cca1de87 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -312,14 +312,14 @@ async fn run_admin(action: &AdminAction, cli: &Cli) -> Result<(), Box let repository = Repository::new(&pool); match action { - AdminAction::ResetPassword { name, password } => { - let id = match repository.user.find_id_by_name(name).await? { - Some(id) => id, - None => return Err(format!("User '{name}' not found").into()), + AdminAction::ResetPassword { email, password } => { + let id = match repository.user.find_by_email(email).await? { + Some(user) => user.id, + None => return Err(format!("User with email '{email}' not found").into()), }; let password_hash = bcrypt::hash(password, bcrypt::DEFAULT_COST)?; repository.user.update_password(&id, &password_hash, false).await?; - println!("Password updated for user '{name}'."); + println!("Password updated for user with email '{email}'."); } } Ok(()) From 3049273942e6ddba0852e94ba0be1b1bd06c7c43 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:36:19 +0530 Subject: [PATCH 60/66] fix(seed): update admin seed message to reference --email flag --- apps/backend/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index cca1de87..71c7fd0a 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -494,7 +494,7 @@ async fn seed_admin(repository: &Repository) { A default admin account was created: email 'admin@cms.local' password 'admin'\n\ Sign in with the email and password. Anyone who can reach this server can log\n\ in until you change it. Run:\n\ - \n vcms admin reset-password --name admin --password \n\n\ + \n vcms admin reset-password --email admin@cms.local --password \n\n\ or change it from the dashboard now. Do NOT expose this server until done.\n\ =========================================================================\n" ); From 9a2926eec552d9760fd3963cb79312ae0dc749f2 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:36:23 +0530 Subject: [PATCH 61/66] fix(handlers): propagate DB errors from target_is_owner for fail-closed authorization --- apps/backend/src/handlers/instance_handler.rs | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/apps/backend/src/handlers/instance_handler.rs b/apps/backend/src/handlers/instance_handler.rs index 764aa278..ed27861d 100644 --- a/apps/backend/src/handlers/instance_handler.rs +++ b/apps/backend/src/handlers/instance_handler.rs @@ -9,15 +9,29 @@ use crate::middleware::auth::{Actor, AuthContext, require_instance_action}; use crate::models::authorization::Action; use crate::models::user::{AdminSetPassword, CreateManagedUser, UpdateInstanceRole, UpdateUserProfile}; use crate::repository::Repository; +use crate::repository::error::RepositoryError; use crate::services::Services; /// True when the target user currently holds the instance-owner role. Editing or deleting /// an owner is owner-only (`InstanceRolesGrant`); everything else needs `InstanceManage`. -async fn target_is_owner(repository: &Repository, user_id: &str) -> bool { - matches!( - repository.user.find_by_id(user_id).await, - Ok(Some(user)) if user.instance_role.as_deref() == Some("instance_owner") +/// Errors propagate so a DB failure fails closed (denies) instead of silently reporting +/// "not an owner" and dropping to the weaker `InstanceManage` gate. +async fn target_is_owner(repository: &Repository, user_id: &str) -> Result { + Ok(repository + .user + .find_by_id(user_id) + .await? + .map(|user| user.instance_role.as_deref() == Some("instance_owner")) + .unwrap_or(false)) +} + +/// Shared 500 response when the owner check itself fails (DB error). +fn owner_check_failed() -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Failed to verify target user" })), ) + .into_response() } pub async fn list_users( @@ -75,11 +89,11 @@ pub async fn update_instance_role( return (status, error).into_response(); } // Anything that grants or revokes the owner role is owner-only. - let target_is_owner = matches!( - repository.user.find_by_id(&user_id).await, - Ok(Some(user)) if user.instance_role.as_deref() == Some("instance_owner") - ); - if (payload.instance_role.as_deref() == Some("instance_owner") || target_is_owner) + let target_owner = match target_is_owner(&repository, &user_id).await { + Ok(value) => value, + Err(_) => return owner_check_failed(), + }; + if (payload.instance_role.as_deref() == Some("instance_owner") || target_owner) && let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await { return (status, error).into_response(); @@ -104,10 +118,15 @@ pub async fn update_user( if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceManage).await { return (status, error).into_response(); } - if target_is_owner(&repository, &user_id).await - && let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await - { - return (status, error).into_response(); + match target_is_owner(&repository, &user_id).await { + Ok(true) => { + if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await + { + return (status, error).into_response(); + } + } + Ok(false) => {} + Err(_) => return owner_check_failed(), } match services .auth @@ -129,10 +148,15 @@ pub async fn set_user_password( if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceManage).await { return (status, error).into_response(); } - if target_is_owner(&repository, &user_id).await - && let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await - { - return (status, error).into_response(); + match target_is_owner(&repository, &user_id).await { + Ok(true) => { + if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await + { + return (status, error).into_response(); + } + } + Ok(false) => {} + Err(_) => return owner_check_failed(), } match services.auth.admin_set_password(&user_id, &payload.new_password).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), @@ -159,10 +183,15 @@ pub async fn delete_user( ) .into_response(); } - if target_is_owner(&repository, &user_id).await - && let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await - { - return (status, error).into_response(); + match target_is_owner(&repository, &user_id).await { + Ok(true) => { + if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await + { + return (status, error).into_response(); + } + } + Ok(false) => {} + Err(_) => return owner_check_failed(), } match services.auth.delete_user(&user_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), From 9a3ded38a825f266cdd90af52f351092cd163ea9 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:36:27 +0530 Subject: [PATCH 62/66] fix(tests): tighten duplicate email registration assertion to expect 409 only --- apps/backend/tests/rest/auth_tests.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/backend/tests/rest/auth_tests.rs b/apps/backend/tests/rest/auth_tests.rs index 48631182..97bc7ad6 100644 --- a/apps/backend/tests/rest/auth_tests.rs +++ b/apps/backend/tests/rest/auth_tests.rs @@ -151,11 +151,9 @@ async fn test_register_duplicate_email() { let status = resp.status(); let body: serde_json::Value = resp.json().await.unwrap_or_default(); - assert!( - status == 409 || status == 400, - "Expected 409 or 400 for duplicate email, got {}: {:?}", - status, - body + assert_eq!( + status, 409, + "Expected 409 Conflict for duplicate email, got {status}: {body:?}" ); } From fa6bd1cd0f6f5713626eea4cd02a2a47b3819c85 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:36:31 +0530 Subject: [PATCH 63/66] fix(dashboard): prefill edit user dialog form fields via useEffect --- .../routes/_admin/_shell/settings/users.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx index 17405361..292b88d6 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { MoreHorizontal, UserPlus } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { CreateUserDialog } from "@/components/instance/create-user-dialog"; import { Badge } from "@/components/ui/badge"; @@ -302,9 +302,18 @@ function EditUserDialog({ onOpenChange: (open: boolean) => void; onSaved: () => void; }) { - const [name, setUsername] = useState(""); + const [name, setName] = useState(""); const [email, setEmail] = useState(""); + // The dialog is controlled via `open={!!user}`, so Radix's onOpenChange does not + // fire when the parent selects a user. Prefill from the user prop instead. + useEffect(() => { + if (user) { + setName(user.name); + setEmail(user.email); + } + }, [user]); + const mutation = useMutation({ mutationFn: () => { if (!user) throw new Error("No user selected"); @@ -319,16 +328,7 @@ function EditUserDialog({ }); return ( - { - if (open && user) { - setUsername(user.name); - setEmail(user.email); - } - onOpenChange(open); - }} - > + Edit user @@ -351,7 +351,7 @@ function EditUserDialog({ required minLength={1} value={name} - onChange={(event) => setUsername(event.target.value)} + onChange={(event) => setName(event.target.value)} /> From 5675f2eb4d33f1f8ee6209639719bbe4505dc651 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:38:12 +0530 Subject: [PATCH 64/66] fix(user): change user existence check from name to email across repositories and tests --- apps/backend/src/main.rs | 8 ++++++-- apps/backend/src/repository/mysql/user.rs | 6 +++--- apps/backend/src/repository/postgres/user.rs | 6 +++--- apps/backend/src/repository/sqlite/user.rs | 6 +++--- apps/backend/src/repository/traits.rs | 2 +- apps/backend/src/test_helpers.rs | 4 ++-- apps/backend/tests/common/server.rs | 2 +- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 71c7fd0a..3000f69d 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -465,15 +465,19 @@ fn parse_scope(scope: &str, site: Option<&str>) -> Result Result { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE name = ?") - .bind(name) + async fn exists(&self, email: &str) -> Result { + let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE email = ?") + .bind(email) .fetch_optional(&self.pool) .await?; diff --git a/apps/backend/src/repository/postgres/user.rs b/apps/backend/src/repository/postgres/user.rs index 71f8ef5e..64e8da16 100644 --- a/apps/backend/src/repository/postgres/user.rs +++ b/apps/backend/src/repository/postgres/user.rs @@ -62,9 +62,9 @@ impl UserRepository for PostgresUserRepository { Ok(()) } - async fn exists(&self, name: &str) -> Result { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE name = $1") - .bind(name) + async fn exists(&self, email: &str) -> Result { + let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE email = $1") + .bind(email) .fetch_optional(&self.pool) .await?; diff --git a/apps/backend/src/repository/sqlite/user.rs b/apps/backend/src/repository/sqlite/user.rs index 0af0fe8f..c2a79a39 100644 --- a/apps/backend/src/repository/sqlite/user.rs +++ b/apps/backend/src/repository/sqlite/user.rs @@ -72,9 +72,9 @@ impl UserRepository for SqliteUserRepository { } } - async fn exists(&self, name: &str) -> Result { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE name = ?") - .bind(name) + async fn exists(&self, email: &str) -> Result { + let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE email = ?") + .bind(email) .fetch_optional(&self.pool) .await?; diff --git a/apps/backend/src/repository/traits.rs b/apps/backend/src/repository/traits.rs index 44331b31..f0aa425f 100644 --- a/apps/backend/src/repository/traits.rs +++ b/apps/backend/src/repository/traits.rs @@ -19,7 +19,7 @@ pub trait UserRepository: Send + Sync { async fn find_by_id(&self, id: &str) -> Result, RepositoryError>; async fn list(&self) -> Result, RepositoryError>; async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError>; - async fn exists(&self, name: &str) -> Result; + async fn exists(&self, email: &str) -> Result; async fn get_role(&self, user_id: &str, site_id: &str) -> Result, RepositoryError>; async fn count(&self) -> Result; async fn count_instance_owners(&self) -> Result; diff --git a/apps/backend/src/test_helpers.rs b/apps/backend/src/test_helpers.rs index 7e18bdbc..983c3294 100644 --- a/apps/backend/src/test_helpers.rs +++ b/apps/backend/src/test_helpers.rs @@ -118,9 +118,9 @@ impl UserRepository for InMemoryUserRepository { Ok(()) } - async fn exists(&self, name: &str) -> Result { + async fn exists(&self, email: &str) -> Result { let users = self.users.lock().unwrap(); - Ok(users.iter().any(|u| u.name == name)) + Ok(users.iter().any(|u| u.email == email)) } async fn get_role(&self, _user_id: &str, _site_id: &str) -> Result, RepositoryError> { diff --git a/apps/backend/tests/common/server.rs b/apps/backend/tests/common/server.rs index b98f057c..7f5803fa 100644 --- a/apps/backend/tests/common/server.rs +++ b/apps/backend/tests/common/server.rs @@ -170,7 +170,7 @@ impl TestServer { } pub(crate) async fn seed_admin(repository: &Repository) { - if !repository.user.exists("admin").await.unwrap_or(false) { + if !repository.user.exists("admin@cms.local").await.unwrap_or(false) { let id = uuid::Uuid::now_v7().to_string(); let password_hash = bcrypt::hash("admin", bcrypt::DEFAULT_COST).expect("Failed to hash password"); repository From b73b742cd053aaf7916175c318b7d8132204e719 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:09:58 +0530 Subject: [PATCH 65/66] fix(openapi): update API title, version, contact, and license information --- apps/backend/src/router/openapi.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/router/openapi.rs b/apps/backend/src/router/openapi.rs index f794b68c..df9fda4f 100644 --- a/apps/backend/src/router/openapi.rs +++ b/apps/backend/src/router/openapi.rs @@ -8,11 +8,11 @@ use crate::models::site::Site; #[derive(OpenApi)] #[openapi( info( - title = "CMS API", - version = "1.0.0", + title = "Velopulent CMS REST API", + version = "0.1.0", description = "Headless CMS unified API. Consumer access uses site-bound vcms_site_* tokens with read or write permission. Dashboard access uses revocable opaque sessions.", - contact(name = "CMS", url = "https://cms.velopulent.com"), - license(name = "MIT") + contact(name = "Velopulent CMS", url = "https://cms.velopulent.com"), + license(name = "AGPL-3.0", url = "https://github.com/velopulent/cms/blob/main/LICENSE"), ), paths( // Public API: Site info From e53c80316911f90b6ec6115189356962bc719442 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:27:24 +0530 Subject: [PATCH 66/66] fix(backup): clarify log message for reconciled interrupted backup jobs --- apps/backend/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 3000f69d..3d85ba65 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -87,7 +87,7 @@ async fn run_serve(cli: &Cli) -> Result<(), Box> { // Reconcile backups/restore jobs left mid-flight by a previous process: any // running/pending row at startup is orphaned (backups only run in-process). match cms::services::backup::meta::fail_orphaned(&pool, &cms::services::backup::now_iso()).await { - Ok(n) if n > 0 => info!("Reconciled {n} interrupted backup/restore job(s) to failed"), + Ok(n) if n > 0 => info!("Reconciled {n} interrupted backup job(s) to failed"), Ok(_) => {} Err(e) => tracing::error!("Failed to reconcile interrupted backups: {e}"), }