diff --git a/backend/app/api/access.py b/backend/app/api/access.py index 0b50aff..7fa9726 100644 --- a/backend/app/api/access.py +++ b/backend/app/api/access.py @@ -21,7 +21,7 @@ from app.core.access import ( require_permission, ) from app.core.config import settings -from app.core.security import issue_token +from app.core.security import hash_password, issue_token, verify_password from app.db.session import get_db from app.models.access import Permission, Role, User @@ -129,6 +129,61 @@ def read_my_permissions(user: User = Depends(get_current_user)): return sorted(get_user_permissions(user)) +class UpdateMeRequest(BaseModel): + name: str | None = None + email: str | None = None + current_password: str | None = None + new_password: str | None = None + + +@router.patch("/me", response_model=UserSession) +def update_me( + payload: UpdateMeRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Allow an internal user to update their own name, email, or password.""" + if payload.new_password: + # Require current password verification before allowing a password change. + # Users who have never set a personal password must supply the shared + # admin password as the current credential. + current_ok = ( + verify_password(payload.current_password or "", user.password_hash) + if user.password_hash + else (payload.current_password or "") == settings.admin_password + ) + if not current_ok: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect", + ) + if len(payload.new_password) < 8: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="New password must be at least 8 characters", + ) + user.password_hash = hash_password(payload.new_password) + + if payload.name is not None: + name = payload.name.strip() + if not name: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty") + user.name = name + + if payload.email is not None: + email = payload.email.strip().lower() + if not email or "@" not in email: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address") + existing = db.scalar(select(User).where(User.email == email, User.id != user.id)) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use") + user.email = email + + db.commit() + db.refresh(user) + return _serialize_session(user, include_token=True) + + # Permission-enforced administrative endpoints. Route bodies should not check # role names — every gate is the require_permission(...) dependency. diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index a814ef7..7c0fc52 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -84,6 +84,28 @@ def ensure_tenant_columns(engine: Engine) -> tuple[str, ...]: return tuple(added_columns) +# Ad-hoc column additions for tables that pre-existed before a column was +# introduced on the model. Each entry is (table, column, DDL fragment). +_LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = ( + ("users", "password_hash", "VARCHAR(255)"), +) + + +def ensure_legacy_columns(engine: Engine) -> tuple[str, ...]: + added: list[str] = [] + for table_name, column_name, ddl in _LEGACY_COLUMN_PATCHES: + if not _table_exists(engine, table_name): + continue + if _has_column(engine, table_name, column_name): + continue + with engine.begin() as connection: + connection.execute( + text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {ddl}") + ) + added.append(f"{table_name}.{column_name}") + return tuple(added) + + def sync_tenant_ids(engine: Engine) -> dict[str, int]: existing_tables = set(inspect(engine).get_table_names()) @@ -339,5 +361,5 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]: def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport: created_tables = ensure_metadata_tables(engine, metadata) - added_columns = ensure_tenant_columns(engine) + added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine) return MigrationReport(created_tables=created_tables, added_columns=added_columns) diff --git a/frontend/package.json b/frontend/package.json index 2d0e497..e3d934c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "data-entry-app-frontend", - "version": "0.1.5", + "version": "0.2.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1074eed..d8e3810 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -284,6 +284,11 @@ export const api = { }), internalSession: (fetcher?: ApiFetch) => request('/api/access/me', { method: 'GET' }, 'client', fetcher), + updateMe: (payload: { name?: string; email?: string; current_password?: string; new_password?: string }) => + request('/api/access/me', { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), adminLogin: (email: string, password: string) => request('/api/auth/admin/login', { method: 'POST', diff --git a/frontend/src/lib/components/AdminShell.svelte b/frontend/src/lib/components/AdminShell.svelte index b823689..549eb8f 100644 --- a/frontend/src/lib/components/AdminShell.svelte +++ b/frontend/src/lib/components/AdminShell.svelte @@ -326,7 +326,7 @@ border: 1px solid rgba(34, 54, 45, 0.1); border-radius: 1.35rem; background: rgba(255, 255, 255, 0.82); - box-shadow: 0 18px 40px rgba(15, 23, 17, 0.08); + box-shadow: none; } .loading-card { diff --git a/frontend/src/lib/components/ClientAccessWorkspace.svelte b/frontend/src/lib/components/ClientAccessWorkspace.svelte index 63913df..208588f 100644 --- a/frontend/src/lib/components/ClientAccessWorkspace.svelte +++ b/frontend/src/lib/components/ClientAccessWorkspace.svelte @@ -775,7 +775,7 @@ flex-shrink: 0; border-radius: 0.8rem; color: #fff; - background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%); + background: var(--color-brand); font-size: 0.76rem; font-weight: 700; letter-spacing: 0.04em; @@ -861,8 +861,8 @@ border-radius: 0.85rem; padding: 0.85rem 1rem; color: #fff; - background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%); - box-shadow: 0 8px 20px rgba(34, 169, 94, 0.2); + background: var(--color-brand); + box-shadow: none; font-weight: 600; cursor: pointer; } @@ -990,7 +990,7 @@ .feature-toggle.enabled { color: #fff; border-color: transparent; - background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%); + background: var(--color-brand); } .feature-toggle:disabled { diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index 172ef2c..d5189ce 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -17,6 +17,7 @@ ShieldCheck, DollarSign, ClipboardList, + TrendingUp, Search, LogOut, Plus, @@ -49,6 +50,7 @@ icon: Calculator, moduleKey: 'mix_calculator' }; + const reportingItem: NavItem = { href: '/reporting', label: 'Reporting', shortLabel: 'RP', icon: TrendingUp, moduleKey: 'products' }; const workingDocumentItems: NavItem[] = [ { href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' }, { href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' }, @@ -111,6 +113,12 @@ description: 'Review delivered product pricing and margins.', keywords: 'products pricing margins delivered outputs' }, + { + href: '/reporting', + label: 'Open Reporting', + description: 'View raw material costs, mix summaries, product pricing, and data quality reports.', + keywords: 'reporting reports raw materials mix cost product pricing data quality price review' + }, { href: '/settings', label: 'Open Workspace Settings', @@ -141,7 +149,7 @@ let seededSearchToken = $state(null); let paletteInput: HTMLInputElement | null = $state(null); const appVersion = `v${packageInfo.version}`; - const releaseStage = 'Alpha'; + const releaseStage = 'Beta'; const currentYear = new Date().getFullYear(); const visibleDashboardItem = $derived( !$clientSession || !dashboardItem.moduleKey || hasModuleAccess($clientSession, dashboardItem.moduleKey) ? dashboardItem : null @@ -156,6 +164,11 @@ ? mixCalculatorItem : null ); + const visibleReportingItem = $derived( + !$clientSession || !reportingItem.moduleKey || hasModuleAccess($clientSession, reportingItem.moduleKey) + ? reportingItem + : null + ); const visibleFooterLinks = $derived([ ...footerLinks, ...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage') @@ -214,6 +227,7 @@ '/products': 'Products', '/scenarios': 'Scenarios', '/client-access': 'Client Access', + '/reporting': 'Reporting', '/settings': 'Settings' }; const section = sectionMap[pathname]; @@ -414,6 +428,15 @@ window.removeEventListener('resize', syncViewport); }; }); + + const userInitials = $derived( + ($clientSession?.name ?? '') + .split(' ') + .slice(0, 2) + .map((w: string) => w[0]) + .join('') + .toUpperCase() || '?' + ); @@ -464,6 +487,14 @@ {visibleMixCalculatorItem.label} {/if} + + {#if visibleReportingItem} + {@const Icon = visibleReportingItem.icon} + + + {visibleReportingItem.label} + + {/if} {#if visibleWorkingDocumentItems.length} @@ -494,17 +525,31 @@ @@ -548,7 +593,10 @@ quickMenuOpen = false; }} > - + + {$clientSession ? userInitials : '?'} + + {$sessionHydrated ? ($clientSession ? 'Signed in' : 'Signed out') : 'Checking session'} {$sessionHydrated ? ($clientSession ? $clientSession.name || 'Client account' : 'Sign in required') : 'Restoring workspace access'} @@ -559,22 +607,28 @@ {#if userMenuOpen}