UI viewer role (#16978)

* db migration

* db model

* assign admin role on password reset

* add role to jwt and api responses

* don't restrict api access for admins yet

* use json response

* frontend auth context

* update auth form for profile endpoint

* add access denied page

* add protected routes

* auth hook

* dialogs

* user settings view

* restrict viewer access to settings

* restrict camera functions for viewer role

* add password dialog to account menu

* spacing tweak

* migrator default to admin

* escape quotes in migrator

* ui tweaks

* tweaks

* colors

* colors

* fix merge conflict

* fix icons

* add api layer enforcement

* ui tweaks

* fix error message

* debug

* clean up

* remove print

* guard apis for admin only

* fix tests

* fix review tests

* use correct error responses from api in toasts

* add role to account menu
This commit is contained in:
Josh Hawkins
2025-03-08 10:01:08 -06:00
committed by GitHub
parent 6f9d9cd5a8
commit 74ca009b0b
50 changed files with 1951 additions and 732 deletions
+200 -29
View File
@@ -2,6 +2,7 @@ import { Button } from "../ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -12,20 +13,31 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import ActivityIndicator from "../indicators/activity-indicator";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Shield, User } from "lucide-react";
import { LuCheck, LuX } from "react-icons/lu";
type CreateUserOverlayProps = {
show: boolean;
onCreate: (user: string, password: string) => void;
onCreate: (user: string, password: string, role: "admin" | "viewer") => void;
onCancel: () => void;
};
export default function CreateUserDialog({
show,
onCreate,
@@ -33,15 +45,22 @@ export default function CreateUserDialog({
}: CreateUserOverlayProps) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const formSchema = z.object({
user: z
.string()
.min(1)
.regex(/^[A-Za-z0-9._]+$/, {
message: "Username may only include letters, numbers, . or _",
}),
password: z.string(),
});
const formSchema = z
.object({
user: z
.string()
.min(1, "Username is required")
.regex(/^[A-Za-z0-9._]+$/, {
message: "Username may only include letters, numbers, . or _",
}),
password: z.string().min(1, "Password is required"),
confirmPassword: z.string().min(1, "Please confirm your password"),
role: z.enum(["admin", "viewer"]),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -49,32 +68,93 @@ export default function CreateUserDialog({
defaultValues: {
user: "",
password: "",
confirmPassword: "",
role: "viewer",
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true);
await onCreate(values.user, values.password);
await onCreate(values.user, values.password, values.role);
form.reset();
setIsLoading(false);
};
// Check if passwords match for real-time feedback
const password = form.watch("password");
const confirmPassword = form.watch("confirmPassword");
const passwordsMatch = password === confirmPassword;
const showMatchIndicator = password && confirmPassword;
useEffect(() => {
if (!show) {
form.reset({
user: "",
password: "",
role: "viewer",
});
}
}, [show, form]);
const handleCancel = () => {
form.reset({
user: "",
password: "",
role: "viewer",
});
onCancel();
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create User</DialogTitle>
<DialogTitle>Create New User</DialogTitle>
<DialogDescription>
Add a new user account and specify an role for access to areas of
the Frigate UI.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 py-4"
>
<FormField
name="user"
render={({ field }) => (
<FormItem>
<FormLabel>User</FormLabel>
<FormLabel className="text-sm font-medium">
Username
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="Enter username"
className="h-10"
{...field}
/>
</FormControl>
<FormDescription className="text-xs text-muted-foreground">
Only letters, numbers, periods and underscores allowed.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
Password
</FormLabel>
<FormControl>
<Input
placeholder="Enter password"
type="password"
className="h-10"
{...field}
/>
</FormControl>
@@ -82,30 +162,121 @@ export default function CreateUserDialog({
</FormItem>
)}
/>
<FormField
name="password"
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel className="text-sm font-medium">
Confirm Password
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="Confirm password"
type="password"
className="h-10"
{...field}
/>
</FormControl>
{showMatchIndicator && (
<div className="mt-1 flex items-center gap-1.5 text-xs">
{passwordsMatch ? (
<>
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">
Passwords match
</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">
Passwords don't match
</span>
</>
)}
</div>
)}
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button
variant="select"
aria-label="Create user"
disabled={isLoading}
>
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
Create User
</Button>
<FormField
name="role"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="h-10">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
value="admin"
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
<span>Admin</span>
</div>
</SelectItem>
<SelectItem
value="viewer"
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>Viewer</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormDescription className="text-xs text-muted-foreground">
Admins have full access to all features in the Frigate UI.
Viewers are limited to viewing cameras, review items, and
historical footage in the UI.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
disabled={isLoading}
onClick={handleCancel}
type="button"
>
Cancel
</Button>
<Button
variant="select"
aria-label="Save"
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</Form>
+43 -16
View File
@@ -6,34 +6,61 @@ import {
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { DialogDescription } from "@radix-ui/react-dialog";
type SetPasswordProps = {
type DeleteUserDialogProps = {
show: boolean;
username?: string;
onDelete: () => void;
onCancel: () => void;
};
export default function DeleteUserDialog({
show,
username,
onDelete,
onCancel,
}: SetPasswordProps) {
}: DeleteUserDialogProps) {
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="flex flex-col items-center gap-2 sm:items-start">
<div className="space-y-1 text-center sm:text-left">
<DialogTitle>Delete User</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
user account and remove all associated data.
</DialogDescription>
</div>
</DialogHeader>
<div>Are you sure?</div>
<DialogFooter>
<Button
className="flex items-center gap-1"
aria-label="Confirm delete"
variant="destructive"
size="sm"
onClick={onDelete}
>
Delete
</Button>
<div className="my-4 rounded-md border border-destructive/20 bg-destructive/5 p-4 text-center text-sm">
<p className="font-medium text-destructive">
Are you sure you want to delete{" "}
<span className="font-bold">{username}</span>?
</p>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
onClick={onCancel}
type="button"
>
Cancel
</Button>
<Button
variant="destructive"
aria-label="Delete"
className="flex flex-1"
onClick={onDelete}
>
Delete User
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
+7 -10
View File
@@ -99,16 +99,13 @@ export default function ExportDialog({
}
})
.catch((error) => {
if (error.response?.data?.message) {
toast.error(
`Failed to start export: ${error.response.data.message}`,
{ position: "top-center" },
);
} else {
toast.error(`Failed to start export: ${error.message}`, {
position: "top-center",
});
}
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to start export: ${errorMessage}`, {
position: "top-center",
});
});
}, [camera, name, range, setRange, setName, setMode]);
@@ -106,16 +106,13 @@ export default function MobileReviewSettingsDrawer({
}
})
.catch((error) => {
if (error.response?.data?.message) {
toast.error(
`Failed to start export: ${error.response.data.message}`,
{ position: "top-center" },
);
} else {
toast.error(`Failed to start export: ${error.message}`, {
position: "top-center",
});
}
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to start export: ${errorMessage}`, {
position: "top-center",
});
});
}, [camera, name, range, setRange, setName, setMode]);
@@ -0,0 +1,119 @@
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { useState } from "react";
import { LuShield, LuUser } from "react-icons/lu";
type RoleChangeDialogProps = {
show: boolean;
username: string;
currentRole: "admin" | "viewer";
onSave: (role: "admin" | "viewer") => void;
onCancel: () => void;
};
export default function RoleChangeDialog({
show,
username,
currentRole,
onSave,
onCancel,
}: RoleChangeDialogProps) {
const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">(
currentRole,
);
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
Change User Role
</DialogTitle>
<DialogDescription>
Update permissions for{" "}
<span className="font-medium">{username}</span>
</DialogDescription>
</DialogHeader>
<div className="py-6">
<div className="mb-4 text-sm text-muted-foreground">
<p>Select the appropriate role for this user:</p>
<ul className="mt-2 space-y-1 pl-5">
<li>
<span className="font-medium">Admin:</span> Full access to all
features.
</li>
<li>
<span className="font-medium">Viewer:</span> Limited to Live
dashboards, Review, Explore, and Exports only.
</li>
</ul>
</div>
<Select
value={selectedRole}
onValueChange={(value) =>
setSelectedRole(value as "admin" | "viewer")
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<LuShield className="size-4 text-primary" />
<span>Admin</span>
</div>
</SelectItem>
<SelectItem value="viewer" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<LuUser className="size-4 text-primary" />
<span>Viewer</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
onClick={onCancel}
type="button"
>
Cancel
</Button>
<Button
variant="select"
aria-label="Save"
className="flex flex-1"
onClick={() => onSave(selectedRole)}
disabled={selectedRole === currentRole}
>
Save
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+175 -23
View File
@@ -1,50 +1,202 @@
"use client";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { useState } from "react";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Label } from "../ui/label";
import { LuCheck, LuX } from "react-icons/lu";
type SetPasswordProps = {
show: boolean;
onSave: (password: string) => void;
onCancel: () => void;
username?: string;
};
export default function SetPasswordDialog({
show,
onSave,
onCancel,
username,
}: SetPasswordProps) {
const [password, setPassword] = useState<string>();
const [password, setPassword] = useState<string>("");
const [confirmPassword, setConfirmPassword] = useState<string>("");
const [passwordStrength, setPasswordStrength] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens/closes
useEffect(() => {
if (show) {
setPassword("");
setConfirmPassword("");
setError(null);
}
}, [show]);
// Simple password strength calculation
useEffect(() => {
if (!password) {
setPasswordStrength(0);
return;
}
let strength = 0;
// Length check
if (password.length >= 8) strength += 1;
// Contains number
if (/\d/.test(password)) strength += 1;
// Contains special char
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
// Contains uppercase
if (/[A-Z]/.test(password)) strength += 1;
setPasswordStrength(strength);
}, [password]);
const handleSave = () => {
if (!password) {
setError("Password cannot be empty");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
onSave(password);
};
const getStrengthLabel = () => {
if (!password) return "";
if (passwordStrength <= 1) return "Weak";
if (passwordStrength === 2) return "Medium";
if (passwordStrength === 3) return "Strong";
return "Very Strong";
};
const getStrengthColor = () => {
if (!password) return "bg-gray-200";
if (passwordStrength <= 1) return "bg-red-500";
if (passwordStrength === 2) return "bg-yellow-500";
if (passwordStrength === 3) return "bg-green-500";
return "bg-green-600";
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Set Password</DialogTitle>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="space-y-2">
<DialogTitle>
{username ? `Update Password for ${username}` : "Set Password"}
</DialogTitle>
<DialogDescription>
Create a strong password to secure this account.
</DialogDescription>
</DialogHeader>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<DialogFooter>
<Button
className="flex items-center gap-1"
aria-label="Save Password"
variant="select"
size="sm"
onClick={() => {
onSave(password!);
}}
>
Save
</Button>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
className="h-10"
type="password"
value={password}
onChange={(event) => {
setPassword(event.target.value);
setError(null);
}}
placeholder="Enter new password"
autoFocus
/>
{/* Password strength indicator */}
{password && (
<div className="mt-2 space-y-1">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
<div
className={`${getStrengthColor()} transition-all duration-300`}
style={{ width: `${(passwordStrength / 3) * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
Password strength:{" "}
<span className="font-medium">{getStrengthLabel()}</span>
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
className="h-10"
type="password"
value={confirmPassword}
onChange={(event) => {
setConfirmPassword(event.target.value);
setError(null);
}}
placeholder="Confirm new password"
/>
{/* Password match indicator */}
{password && confirmPassword && (
<div className="mt-1 flex items-center gap-1.5 text-xs">
{password === confirmPassword ? (
<>
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">Passwords match</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">Passwords don't match</span>
</>
)}
</div>
)}
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
onClick={onCancel}
type="button"
>
Cancel
</Button>
<Button
variant="select"
aria-label="Save"
className="flex flex-1"
onClick={handleSave}
disabled={!password || password !== confirmPassword}
>
Save
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -87,10 +87,13 @@ export function AnnotationSettingsPane({
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
})
.finally(() => {
setIsLoading(false);
@@ -394,8 +394,12 @@ function ObjectDetailsTab({
},
);
})
.catch(() => {
toast.error("Failed to update the description", {
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to update the description: ${errorMessage}`, {
position: "top-center",
});
setDesc(search.data.description);
@@ -422,11 +426,13 @@ function ObjectDetailsTab({
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${error.response.data.message}`,
{
position: "top-center",
},
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${errorMessage}`,
{ position: "top-center" },
);
});
},
@@ -492,8 +498,12 @@ function ObjectDetailsTab({
setIsSubLabelDialogOpen(false);
}
})
.catch(() => {
toast.error("Failed to update sub label.", {
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to update sub label: ${errorMessage}`, {
position: "top-center",
});
});