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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-15 18:37:16 +01:00
parent f60b7261b5
commit 075bbb69e5
11 changed files with 334 additions and 62 deletions

View file

@ -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'")

View file

@ -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",
)

View file

@ -11,6 +11,7 @@ class UserRole(str, enum.Enum):
admin = "admin"
tm_manager = "tm_manager"
reviewer = "reviewer"
viewer = "viewer"
class UserStatus(str, enum.Enum):

View file

@ -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}

View file

@ -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<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(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 (
<AppShell>
<div className="space-y-6">
@ -59,7 +137,26 @@ export default function UsersPage() {
</Button>
</div>
<UserTable users={users} onEdit={handleEdit} />
{loading && (
<div className="flex items-center justify-center py-12 text-gray-400 gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm">Loading users</span>
</div>
)}
{error && !loading && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
{!loading && !error && (
<UserTable
users={users}
onEdit={handleEdit}
onToggleActive={handleToggleActive}
/>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
@ -72,7 +169,8 @@ export default function UsersPage() {
<div className="space-y-2">
<Label>Full Name</Label>
<Input
defaultValue={editUser?.name || ""}
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Enter full name"
/>
</div>
@ -80,36 +178,51 @@ export default function UsersPage() {
<Label>Email</Label>
<Input
type="email"
defaultValue={editUser?.email || ""}
placeholder="user@amazon.com"
value={formEmail}
onChange={(e) => setFormEmail(e.target.value)}
placeholder="user@oliver.agency"
/>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select defaultValue={editUser?.role || "VIEWER"}>
<Select value={formRole} onValueChange={setFormRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="MANAGER">Manager</SelectItem>
<SelectItem value="LINGUIST">Linguist</SelectItem>
<SelectItem value="VIEWER">Viewer</SelectItem>
{ROLE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!editUser && (
<div className="space-y-2">
<Label>Temporary Password</Label>
<Input type="password" placeholder="Set initial password" />
<Input
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
placeholder="Set initial password"
/>
</div>
)}
{saveError && (
<p className="text-sm text-red-500">{saveError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={saving}
>
Cancel
</Button>
<Button onClick={() => setDialogOpen(false)}>
<Button onClick={handleSave} disabled={saving} className="gap-2">
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{editUser ? "Save Changes" : "Create User"}
</Button>
</DialogFooter>

View file

@ -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<string, "green" | "blue" | "amber" | "gray" | "default"> = {
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 (
<Card>
<Table>
@ -65,7 +67,7 @@ export function UserTable({ users, onEdit }: UserTableProps) {
</TableCell>
<TableCell>
<Badge variant={roleVariant[user.role] || "gray"}>
{user.role}
{getRoleDisplayName(user.role)}
</Badge>
</TableCell>
<TableCell>
@ -92,7 +94,10 @@ export function UserTable({ users, onEdit }: UserTableProps) {
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-amazon-error">
<DropdownMenuItem
className="text-amazon-error"
onClick={() => onToggleActive(user)}
>
{user.is_active ? "Deactivate" : "Activate"}
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -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 */}
<nav className="flex-1 px-3 py-2 space-y-0.5 overflow-y-auto">
{navItems.map((item) => {
{visibleNavItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
@ -122,11 +138,6 @@ export function Sidebar() {
)}
<Icon className={cn("h-[18px] w-[18px] shrink-0", active ? "text-amazon-orange" : "text-gray-400 group-hover:text-gray-300")} />
<span className="font-medium">{item.label}</span>
{item.adminOnly && (
<span className="ml-auto text-[10px] font-medium text-gray-500 bg-white/5 px-1.5 py-0.5 rounded">
ADMIN
</span>
)}
</Link>
);
})}
@ -136,17 +147,20 @@ export function Sidebar() {
<div className="p-3 border-t border-white/10">
<div className="flex items-center gap-3 px-3 py-2">
<div className="h-9 w-9 rounded-full bg-amazon-orange flex items-center justify-center text-white text-sm font-bold shrink-0">
{getInitials(mockUser.name)}
{getInitials(currentUser?.name || "U")}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{mockUser.name}
{currentUser?.name || "User"}
</p>
<p className="text-xs text-gray-400 truncate">
{currentUser ? getRoleDisplayName(currentUser.role) : ""}
</p>
<p className="text-xs text-gray-400 truncate">{mockUser.role}</p>
</div>
<button
className="text-gray-400 hover:text-white transition-colors p-1"
title="Sign out"
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
</button>

View file

@ -437,24 +437,54 @@ export async function submitFeedback(
// ─── Users ───────────────────────────────────────────────────────────
export async function getUsers(): Promise<User[]> {
const response = await api.get<User[]>("/users");
return response.data;
/* eslint-disable @typescript-eslint/no-explicit-any */
function mapUserResponse(data: any): User {
return {
id: data.id,
email: data.email,
name: data.name,
role: ((data.role as string) || "").toUpperCase() as User["role"],
is_active: (data.status as string) === "active",
created_at: data.created_at,
updated_at: data.updated_at,
last_login: data.last_login,
};
}
/* eslint-enable @typescript-eslint/no-explicit-any */
export async function getUsers(
page = 1,
pageSize = 50
): Promise<PaginatedResponse<User>> {
const response = await api.get("/users", {
params: { page, page_size: pageSize },
});
return {
...response.data,
items: response.data.items.map(mapUserResponse),
};
}
export async function createUser(
data: Partial<User> & { password: string }
): Promise<User> {
const response = await api.post<User>("/users", data);
return response.data;
export async function createUser(data: {
email: string;
name: string;
password: string;
role: string;
client_ids?: string[];
}): Promise<User> {
const payload = { ...data, role: data.role.toLowerCase() };
const response = await api.post("/users", payload);
return mapUserResponse(response.data);
}
export async function updateUser(
userId: string,
data: Partial<User>
data: { name?: string; email?: string; role?: string; status?: string }
): Promise<User> {
const response = await api.put<User>(`/users/${userId}`, data);
return response.data;
const payload = { ...data };
if (payload.role) payload.role = payload.role.toLowerCase();
const response = await api.put(`/users/${userId}`, payload);
return mapUserResponse(response.data);
}
export async function deleteUser(userId: string): Promise<void> {

View file

@ -95,3 +95,14 @@ export function hasRole(requiredRole: string): boolean {
const userRole = user.role.toUpperCase();
return (roleHierarchy[userRole] || 0) >= (roleHierarchy[requiredRole] || 0);
}
export const ROLE_DISPLAY_NAMES: Record<string, string> = {
ADMIN: "Admin",
TM_MANAGER: "TM Manager",
REVIEWER: "Reviewer",
VIEWER: "Viewer",
};
export function getRoleDisplayName(role: string): string {
return ROLE_DISPLAY_NAMES[role?.toUpperCase()] || role;
}

View file

@ -27,8 +27,8 @@ export enum ConfidenceTier {
export enum UserRole {
ADMIN = "ADMIN",
MANAGER = "MANAGER",
LINGUIST = "LINGUIST",
TM_MANAGER = "TM_MANAGER",
REVIEWER = "REVIEWER",
VIEWER = "VIEWER",
}
@ -71,6 +71,7 @@ export interface User {
role: UserRole;
avatar_url?: string;
created_at: string;
updated_at?: string;
last_login?: string;
is_active: boolean;
}

View file

@ -0,0 +1,71 @@
"""Seed script: Ensure default admin users exist (SSO-only, Azure AD).
Run once after deployment to guarantee these accounts have admin role.
If the user already exists (e.g. created via SSO with viewer role), their
role is upgraded to admin. If they don't exist yet, they are pre-created
so that SSO login will find the existing admin record rather than
auto-provisioning them as viewer.
"""
import asyncio
import sys
from pathlib import Path
_seed_dir = Path(__file__).resolve().parent
_backend_dir = _seed_dir.parent / "backend"
if _backend_dir.is_dir():
sys.path.insert(0, str(_backend_dir))
else:
sys.path.insert(0, str(_seed_dir.parent))
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
from app.models.user import User, UserRole, UserStatus
# These accounts will always have admin role.
# They log in via Azure AD SSO — no password needed.
DEFAULT_ADMINS = [
{"email": "daveporter@oliver.agency", "name": "Dave Porter"},
{"email": "vadymsamoilenko@oliver.agency", "name": "Vadym Samoilenko"},
]
async def seed_default_admins() -> None:
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True)
factory = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as db:
for admin_data in DEFAULT_ADMINS:
result = await db.execute(
select(User).where(User.email == admin_data["email"])
)
user = result.scalar_one_or_none()
if user:
if user.role != UserRole.admin:
user.role = UserRole.admin
print(f"Upgraded '{admin_data['email']}' to admin (was {user.role.value})")
else:
print(f"'{admin_data['email']}' is already admin — no change")
else:
user = User(
email=admin_data["email"],
name=admin_data["name"],
password_hash=None,
role=UserRole.admin,
status=UserStatus.active,
auth_provider="azure_ad",
)
db.add(user)
print(f"Created admin '{admin_data['email']}'")
await db.commit()
await engine.dispose()
print("\nDone. These accounts will have admin role on next SSO login.")
if __name__ == "__main__":
asyncio.run(seed_default_admins())