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:
parent
f60b7261b5
commit
075bbb69e5
11 changed files with 334 additions and 62 deletions
26
backend/alembic/versions/d3e4f5a6b7c8_add_viewer_role.py
Normal file
26
backend/alembic/versions/d3e4f5a6b7c8_add_viewer_role.py
Normal 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'")
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class UserRole(str, enum.Enum):
|
|||
admin = "admin"
|
||||
tm_manager = "tm_manager"
|
||||
reviewer = "reviewer"
|
||||
viewer = "viewer"
|
||||
|
||||
|
||||
class UserStatus(str, enum.Enum):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
71
seed/create_default_admins.py
Normal file
71
seed/create_default_admins.py
Normal 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())
|
||||
Loading…
Add table
Reference in a new issue