From 075bbb69e5a8aaa9774a4e44b6f864f05b25a64b Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Wed, 15 Apr 2026 18:37:16 +0100 Subject: [PATCH] Implement user management: viewer role, real API wiring, admin sidebar - Add viewer role to backend enum + Alembic migration - SSO auto-provisioned users now get viewer (lowest privilege) by default - Wire admin/users page to real API (replace mock data), with add/edit/deactivate - Fix frontend UserRole enum to match backend (TM_MANAGER, REVIEWER) - Replace hardcoded mock user in Sidebar with real auth, filter admin-only nav items, wire logout - Add seed script to set default admins (daveporter, vadymsamoilenko) Co-Authored-By: Claude Sonnet 4.6 --- .../versions/d3e4f5a6b7c8_add_viewer_role.py | 26 +++ backend/app/auth/service.py | 2 +- backend/app/models/user.py | 1 + backend/app/schemas/user.py | 2 +- frontend/src/app/admin/users/page.tsx | 157 +++++++++++++++--- frontend/src/components/admin/UserTable.tsx | 15 +- frontend/src/components/layout/Sidebar.tsx | 54 +++--- frontend/src/lib/api.ts | 52 ++++-- frontend/src/lib/auth.ts | 11 ++ frontend/src/lib/types.ts | 5 +- seed/create_default_admins.py | 71 ++++++++ 11 files changed, 334 insertions(+), 62 deletions(-) create mode 100644 backend/alembic/versions/d3e4f5a6b7c8_add_viewer_role.py create mode 100644 seed/create_default_admins.py diff --git a/backend/alembic/versions/d3e4f5a6b7c8_add_viewer_role.py b/backend/alembic/versions/d3e4f5a6b7c8_add_viewer_role.py new file mode 100644 index 0000000..0a5ef06 --- /dev/null +++ b/backend/alembic/versions/d3e4f5a6b7c8_add_viewer_role.py @@ -0,0 +1,26 @@ +"""add viewer to user_role enum + +Revision ID: d3e4f5a6b7c8 +Revises: c1d2e3f4a5b6 +Create Date: 2026-04-15 + +""" + +from alembic import op + +revision = "d3e4f5a6b7c8" +down_revision = "c1d2e3f4a5b6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add 'viewer' to the existing user_role enum type. + # IF NOT EXISTS prevents errors if already present (idempotent). + op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'viewer'") + + +def downgrade() -> None: + # PostgreSQL cannot remove values from an enum type. + # Demote any viewer users to reviewer before any manual type recreation. + op.execute("UPDATE users SET role = 'reviewer' WHERE role = 'viewer'") diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index cbd7c9a..369b5ef 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -68,7 +68,7 @@ class AuthService: email=email, name=name, password_hash=None, - role=UserRole.reviewer, + role=UserRole.viewer, status=UserStatus.active, auth_provider="azure_ad", ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1555c8d..5d8fbd0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -11,6 +11,7 @@ class UserRole(str, enum.Enum): admin = "admin" tm_manager = "tm_manager" reviewer = "reviewer" + viewer = "viewer" class UserStatus(str, enum.Enum): diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 6af841e..4c3d377 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -10,7 +10,7 @@ class UserCreate(BaseModel): email: EmailStr name: str password: str - role: UserRole = UserRole.reviewer + role: UserRole = UserRole.viewer client_ids: list[UUID] = [] model_config = {"from_attributes": True} diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx index b76272a..7d9fcba 100644 --- a/frontend/src/app/admin/users/page.tsx +++ b/frontend/src/app/admin/users/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { AppShell } from "@/components/layout/AppShell"; import { UserTable } from "@/components/admin/UserTable"; import { Button } from "@/components/ui/button"; @@ -20,32 +20,110 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { UserPlus } from "lucide-react"; -import type { User, UserRole } from "@/lib/types"; +import { UserPlus, Loader2 } from "lucide-react"; +import type { User } from "@/lib/types"; +import { getUsers, createUser, updateUser } from "@/lib/api"; -const mockUsers: User[] = [ - { id: "u-001", email: "s.chen@amazon.com", name: "Sarah Chen", role: "ADMIN" as UserRole, created_at: "2026-01-15T00:00:00Z", last_login: "2026-04-10T08:30:00Z", is_active: true }, - { id: "u-002", email: "j.miller@amazon.com", name: "James Miller", role: "MANAGER" as UserRole, created_at: "2026-02-01T00:00:00Z", last_login: "2026-04-09T14:20:00Z", is_active: true }, - { id: "u-003", email: "e.brown@amazon.com", name: "Emily Brown", role: "LINGUIST" as UserRole, created_at: "2026-02-15T00:00:00Z", last_login: "2026-04-08T11:45:00Z", is_active: true }, - { id: "u-004", email: "m.garcia@amazon.com", name: "Maria Garcia", role: "LINGUIST" as UserRole, created_at: "2026-03-01T00:00:00Z", last_login: "2026-04-07T16:00:00Z", is_active: true }, - { id: "u-005", email: "d.schmidt@amazon.com", name: "Daniel Schmidt", role: "VIEWER" as UserRole, created_at: "2026-03-15T00:00:00Z", last_login: "2026-04-05T09:15:00Z", is_active: false }, +const ROLE_OPTIONS = [ + { value: "ADMIN", label: "Admin" }, + { value: "TM_MANAGER", label: "TM Manager" }, + { value: "REVIEWER", label: "Reviewer" }, + { value: "VIEWER", label: "Viewer" }, ]; export default function UsersPage() { - const [users] = useState(mockUsers); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [editUser, setEditUser] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + // Controlled form state + const [formName, setFormName] = useState(""); + const [formEmail, setFormEmail] = useState(""); + const [formRole, setFormRole] = useState("VIEWER"); + const [formPassword, setFormPassword] = useState(""); + + const fetchUsers = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await getUsers(); + setUsers(result.items); + } catch { + setError("Failed to load users. Make sure you have admin access."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); const handleEdit = (user: User) => { setEditUser(user); + setFormName(user.name); + setFormEmail(user.email); + setFormRole(user.role); + setFormPassword(""); + setSaveError(null); setDialogOpen(true); }; const handleAdd = () => { setEditUser(null); + setFormName(""); + setFormEmail(""); + setFormRole("VIEWER"); + setFormPassword(""); + setSaveError(null); setDialogOpen(true); }; + const handleToggleActive = async (user: User) => { + const newStatus = user.is_active ? "inactive" : "active"; + try { + const updated = await updateUser(user.id, { status: newStatus }); + setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u))); + } catch { + // Silently fail — user can retry + } + }; + + const handleSave = async () => { + setSaving(true); + setSaveError(null); + try { + if (editUser) { + const updated = await updateUser(editUser.id, { + name: formName, + email: formEmail, + role: formRole, + }); + setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u))); + } else { + const created = await createUser({ + name: formName, + email: formEmail, + role: formRole, + password: formPassword, + }); + setUsers((prev) => [created, ...prev]); + } + setDialogOpen(false); + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || + "Failed to save user."; + setSaveError(msg); + } finally { + setSaving(false); + } + }; + return (
@@ -59,7 +137,26 @@ export default function UsersPage() {
- + {loading && ( +
+ + Loading users… +
+ )} + + {error && !loading && ( +
+ {error} +
+ )} + + {!loading && !error && ( + + )} @@ -72,7 +169,8 @@ export default function UsersPage() {
setFormName(e.target.value)} placeholder="Enter full name" />
@@ -80,36 +178,51 @@ export default function UsersPage() { setFormEmail(e.target.value)} + placeholder="user@oliver.agency" />
- - Admin - Manager - Linguist - Viewer + {ROLE_OPTIONS.map((opt) => ( + + {opt.label} + + ))}
{!editUser && (
- + setFormPassword(e.target.value)} + placeholder="Set initial password" + />
)} + {saveError && ( +

{saveError}

+ )} - - diff --git a/frontend/src/components/admin/UserTable.tsx b/frontend/src/components/admin/UserTable.tsx index b87eec7..5039d16 100644 --- a/frontend/src/components/admin/UserTable.tsx +++ b/frontend/src/components/admin/UserTable.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import type { User } from "@/lib/types"; import { formatDate, getInitials } from "@/lib/utils"; +import { getRoleDisplayName } from "@/lib/auth"; import { Pencil, MoreHorizontal } from "lucide-react"; import { DropdownMenu, @@ -25,17 +26,18 @@ import { const roleVariant: Record = { ADMIN: "default", - MANAGER: "blue", - LINGUIST: "green", + TM_MANAGER: "blue", + REVIEWER: "green", VIEWER: "gray", }; interface UserTableProps { users: User[]; onEdit: (user: User) => void; + onToggleActive: (user: User) => void; } -export function UserTable({ users, onEdit }: UserTableProps) { +export function UserTable({ users, onEdit, onToggleActive }: UserTableProps) { return ( @@ -65,7 +67,7 @@ export function UserTable({ users, onEdit }: UserTableProps) { - {user.role} + {getRoleDisplayName(user.role)} @@ -92,7 +94,10 @@ export function UserTable({ users, onEdit }: UserTableProps) { Edit - + onToggleActive(user)} + > {user.is_active ? "Deactivate" : "Activate"} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 48cdea2..8c47bcc 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,9 +1,9 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import Link from "next/link"; import Image from "next/image"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname, useSearchParams, useRouter } from "next/navigation"; import { LayoutDashboard, PlusCircle, @@ -21,6 +21,7 @@ import { X, } from "lucide-react"; import { cn, getInitials } from "@/lib/utils"; +import { getUser, clearAuth, getRoleDisplayName } from "@/lib/auth"; interface NavItem { label: string; @@ -43,24 +44,39 @@ const navItems: NavItem[] = [ { label: "Help", href: "/help", icon: HelpCircle }, ]; -// Mock user for display -const mockUser = { - name: "Sarah Chen", - role: "ADMIN", - email: "s.chen@amazon.com", -}; - export function Sidebar() { const pathname = usePathname(); const searchParams = useSearchParams(); + const router = useRouter(); const [mobileOpen, setMobileOpen] = useState(false); + const [currentUser, setCurrentUser] = useState<{ + name: string; + role: string; + email: string; + } | null>(null); + + useEffect(() => { + const stored = getUser(); + if (stored) { + setCurrentUser({ name: stored.name, role: stored.role, email: stored.email }); + } + }, []); + + const handleLogout = () => { + clearAuth(); + router.push("/login"); + }; + + const isAdmin = currentUser?.role === "ADMIN"; + + const visibleNavItems = navItems.filter( + (item) => !item.adminOnly || isAdmin + ); const isActive = (href: string) => { const [hrefPath, hrefQuery] = href.split("?"); const basePath = hrefPath; - // For items that share the same base path but differ by query params, - // we need exact matching including query params if (basePath === "/dashboard") { const hrefHasView = hrefQuery?.includes("view=monitoring"); const currentHasView = searchParams.get("view") === "monitoring"; @@ -101,7 +117,7 @@ export function Sidebar() { {/* Navigation */}