Settings as panels, the way the portal does it
Four tabs over three cards was navigation for its own sake, and the two shells disagreed about what a settings screen looks like. It is one grid of cards now, matching the portal: the account card carries changing a password and deleting the account in its footer, appearance is a card with one row, and remote access — an operator's concern, not a personal preference — is a card of its own for a superuser. SettingRow, alert-dialog and UserAvatar come across from the portal, so an account renders the same face in both shells. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@xyflow/react": "^12.11.3",
|
"@xyflow/react": "^12.11.3",
|
||||||
"axios": "1.13.2",
|
"axios": "1.13.2",
|
||||||
|
"boring-avatars": "^2.0.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -535,6 +536,8 @@
|
|||||||
|
|
||||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
|
"boring-avatars": ["boring-avatars@2.0.4", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw=="],
|
||||||
|
|
||||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@xyflow/react": "^12.11.3",
|
"@xyflow/react": "^12.11.3",
|
||||||
"axios": "1.13.2",
|
"axios": "1.13.2",
|
||||||
|
"boring-avatars": "^2.0.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Avatar from "boring-avatars"
|
||||||
|
|
||||||
|
import useAuth from "@/hooks/useAuth"
|
||||||
|
|
||||||
|
// Shared avatar identity config, kept in step with the portal's copy
|
||||||
|
// (index/frontend/src/components/Common/UserAvatar.tsx) so one account renders
|
||||||
|
// the same face in both shells. The seed is the email, the only stable per-user
|
||||||
|
// id both sides hold. These are decorative identity colours, not design tokens.
|
||||||
|
export const AVATAR_VARIANT = "marble" as const
|
||||||
|
export const AVATAR_COLORS = [
|
||||||
|
"#EAAC0F",
|
||||||
|
"#e9e1cf",
|
||||||
|
"#18150f",
|
||||||
|
"#5d9a69",
|
||||||
|
"#9b8450",
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Deterministic boring-avatars icon for the current user (seeded by email). */
|
||||||
|
export function UserAvatar({
|
||||||
|
size = 40,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
name={user?.email ?? "user"}
|
||||||
|
variant={AVATAR_VARIANT}
|
||||||
|
colors={AVATAR_COLORS}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react"
|
import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react"
|
||||||
|
|
||||||
import { type Theme, useTheme } from "@/components/theme-provider"
|
import { type Theme, useTheme } from "@/components/theme-provider"
|
||||||
|
import SettingRow from "@/components/UserSettings/SettingRow"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const options: { value: Theme; title: string; icon: LucideIcon }[] = [
|
const options: { value: Theme; title: string; icon: LucideIcon }[] = [
|
||||||
@@ -13,13 +20,16 @@ const Appearance = () => {
|
|||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md">
|
<Card>
|
||||||
<h3 className="text-lg font-semibold py-4">Appearance</h3>
|
<CardHeader>
|
||||||
<p className="text-sm text-muted-foreground pb-4">
|
<CardTitle>Appearance</CardTitle>
|
||||||
Choose how Fluksio looks to you. System follows your device setting.
|
<CardDescription>How Fluksio looks to you</CardDescription>
|
||||||
</p>
|
</CardHeader>
|
||||||
{/* The one segmented shape: a single border pill, transparent segments,
|
<CardContent>
|
||||||
bg-accent on the selected one (root DESIGN-GUIDELINES.md). */}
|
<SettingRow label="Theme" hint="System follows your device setting.">
|
||||||
|
{/* The one segmented shape: a single border pill, transparent
|
||||||
|
segments, bg-accent on the selected one (root
|
||||||
|
DESIGN-GUIDELINES.md). */}
|
||||||
<div
|
<div
|
||||||
data-testid="theme-button"
|
data-testid="theme-button"
|
||||||
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
|
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
|
||||||
@@ -43,7 +53,9 @@ const Appearance = () => {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SettingRow>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useMutation } from "@tanstack/react-query"
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import { useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { type UpdatePassword, UsersService } from "@/client"
|
import { type UpdatePassword, UsersService } from "@/client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -39,6 +48,7 @@ const formSchema = z
|
|||||||
type FormData = z.infer<typeof formSchema>
|
type FormData = z.infer<typeof formSchema>
|
||||||
|
|
||||||
const ChangePassword = () => {
|
const ChangePassword = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@@ -57,6 +67,7 @@ const ChangePassword = () => {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccessToast("Password updated successfully")
|
showSuccessToast("Password updated successfully")
|
||||||
form.reset()
|
form.reset()
|
||||||
|
setIsOpen(false)
|
||||||
},
|
},
|
||||||
onError: handleError.bind(showErrorToast),
|
onError: handleError.bind(showErrorToast),
|
||||||
})
|
})
|
||||||
@@ -65,9 +76,23 @@ const ChangePassword = () => {
|
|||||||
mutation.mutate(data)
|
mutation.mutate(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onOpenChange = (open: boolean) => {
|
||||||
|
if (!open) form.reset()
|
||||||
|
setIsOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md">
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<h3 className="text-lg font-semibold py-4">Change Password</h3>
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Change password
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent showCloseButton>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Change password</DialogTitle>
|
||||||
|
<DialogDescription>Update your account password</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
@@ -133,13 +158,14 @@ const ChangePassword = () => {
|
|||||||
<LoadingButton
|
<LoadingButton
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={mutation.isPending}
|
loading={mutation.isPending}
|
||||||
className="self-start"
|
className="self-end"
|
||||||
>
|
>
|
||||||
Update Password
|
Update Password
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import DeleteConfirmation from "./DeleteConfirmation"
|
|
||||||
|
|
||||||
const DeleteAccount = () => {
|
|
||||||
return (
|
|
||||||
<div className="max-w-md mt-4 rounded-lg border border-destructive/50 p-4">
|
|
||||||
<h3 className="font-semibold text-destructive">Delete Account</h3>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Permanently delete your account and all associated data.
|
|
||||||
</p>
|
|
||||||
<DeleteConfirmation />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DeleteAccount
|
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
|
|
||||||
import { UsersService } from "@/client"
|
import { UsersService } from "@/client"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
AlertDialog,
|
||||||
DialogClose,
|
AlertDialogContent,
|
||||||
DialogContent,
|
AlertDialogDescription,
|
||||||
DialogDescription,
|
AlertDialogFooter,
|
||||||
DialogFooter,
|
AlertDialogHeader,
|
||||||
DialogHeader,
|
AlertDialogTitle,
|
||||||
DialogTitle,
|
} from "@/components/ui/alert-dialog"
|
||||||
DialogTrigger,
|
import { Button } from "@/components/ui/button"
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { LoadingButton } from "@/components/ui/loading-button"
|
import { LoadingButton } from "@/components/ui/loading-button"
|
||||||
import useAuth from "@/hooks/useAuth"
|
import useAuth from "@/hooks/useAuth"
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
import { handleError } from "@/utils"
|
import { handleError } from "@/utils"
|
||||||
|
|
||||||
const DeleteConfirmation = () => {
|
const DeleteConfirmation = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
const { handleSubmit } = useForm()
|
const { handleSubmit } = useForm()
|
||||||
@@ -41,41 +40,49 @@ const DeleteConfirmation = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<>
|
||||||
<DialogTrigger asChild>
|
<Button
|
||||||
<Button variant="destructive" className="mt-3">
|
variant="ghost"
|
||||||
Delete Account
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
|
Delete account
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogContent>
|
<AlertDialogContent>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<DialogHeader>
|
<AlertDialogHeader>
|
||||||
<DialogTitle>Confirmation Required</DialogTitle>
|
<AlertDialogTitle>Confirmation Required</AlertDialogTitle>
|
||||||
<DialogDescription>
|
<AlertDialogDescription>
|
||||||
All your account data will be{" "}
|
All your account data will be{" "}
|
||||||
<strong>permanently deleted.</strong> If you are sure, please
|
<strong>permanently deleted.</strong> If you are sure, please
|
||||||
click <strong>"Confirm"</strong> to proceed. This action cannot be
|
click <strong>"Confirm"</strong> to proceed. This action cannot
|
||||||
undone.
|
be undone.
|
||||||
</DialogDescription>
|
</AlertDialogDescription>
|
||||||
</DialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<AlertDialogFooter className="mt-4">
|
||||||
<DialogClose asChild>
|
<Button
|
||||||
<Button variant="outline" disabled={mutation.isPending}>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
variant="destructive"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
loading={mutation.isPending}
|
loading={mutation.isPending}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</DialogFooter>
|
</AlertDialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</AlertDialogContent>
|
||||||
</Dialog>
|
</AlertDialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { useState } from "react"
|
|||||||
|
|
||||||
import { CloudService } from "@/client"
|
import { CloudService } from "@/client"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -13,6 +20,7 @@ import {
|
|||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
import { handleError } from "@/utils"
|
import { handleError } from "@/utils"
|
||||||
|
|
||||||
@@ -96,18 +104,18 @@ export function RemoteAccess() {
|
|||||||
if (!status) return null
|
if (!status) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex max-w-2xl flex-col gap-6 py-4">
|
<Card>
|
||||||
<div>
|
<CardHeader>
|
||||||
<h2 className="text-lg font-medium">Remote access</h2>
|
<CardTitle>Remote access</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<CardDescription>
|
||||||
Reach this installation from fluksio.com. Entirely optional — without
|
Reach this installation from fluksio.com. Entirely optional — without
|
||||||
it, this installation talks to nothing outside your network.
|
it, this installation talks to nothing outside your network.
|
||||||
</p>
|
</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-6">
|
||||||
{status.enrolled ? (
|
{status.enrolled ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
|
<div className="flex flex-col gap-4">
|
||||||
<dl className="grid gap-3 sm:grid-cols-2">
|
<dl className="grid gap-3 sm:grid-cols-2">
|
||||||
<Field label="Status">
|
<Field label="Status">
|
||||||
{status.connected
|
{status.connected
|
||||||
@@ -121,8 +129,8 @@ export function RemoteAccess() {
|
|||||||
{status.portal_account ?? "—"}
|
{status.portal_account ?? "—"}
|
||||||
<span className="mt-1 block text-xs text-muted-foreground">
|
<span className="mt-1 block text-xs text-muted-foreground">
|
||||||
Portal sessions of this account get this account's rights
|
Portal sessions of this account get this account's rights
|
||||||
here. Anyone else gets in only once added below, as their own
|
here. Anyone else gets in only once added below, as their
|
||||||
user.
|
own user.
|
||||||
</span>
|
</span>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Installation">
|
<Field label="Installation">
|
||||||
@@ -142,7 +150,9 @@ export function RemoteAccess() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">Remote users</h3>
|
<h3 className="font-medium">Remote users</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -181,7 +191,7 @@ export function RemoteAccess() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="portal-url">Portal</Label>
|
<Label htmlFor="portal-url">Portal</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -207,7 +217,9 @@ export function RemoteAccess() {
|
|||||||
<Button
|
<Button
|
||||||
variant="brand"
|
variant="brand"
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
disabled={!code.trim() || !portalUrl.trim() || connect.isPending}
|
disabled={
|
||||||
|
!code.trim() || !portalUrl.trim() || connect.isPending
|
||||||
|
}
|
||||||
onClick={() => connect.mutate()}
|
onClick={() => connect.mutate()}
|
||||||
>
|
>
|
||||||
{connect.isPending ? "Connecting…" : "Connect"}
|
{connect.isPending ? "Connecting…" : "Connect"}
|
||||||
@@ -215,6 +227,7 @@ export function RemoteAccess() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
<Dialog open={confirmDisconnect} onOpenChange={setConfirmDisconnect}>
|
<Dialog open={confirmDisconnect} onOpenChange={setConfirmDisconnect}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -245,7 +258,7 @@ export function RemoteAccess() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label + hint on the left, its control on the right. Shared by the settings
|
||||||
|
* cards. The row wraps when the two no longer fit side by side, so on a phone
|
||||||
|
* the control sits under its label instead of squeezing it to one word.
|
||||||
|
*/
|
||||||
|
const SettingRow = ({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
children: ReactNode
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||||
|
{/* basis-40 is the width the label keeps before the control drops to its
|
||||||
|
own line; without it a long hint would push every control down, even
|
||||||
|
on a wide card where both fit next to each other. */}
|
||||||
|
<div className="min-w-0 grow basis-40">
|
||||||
|
<Label className="font-normal">{label}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default SettingRow
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Pencil } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { UsersService, type UserUpdateMe } from "@/client"
|
import { UsersService, type UserUpdateMe } from "@/client"
|
||||||
|
import { UserAvatar } from "@/components/Common/UserAvatar"
|
||||||
|
import ChangePassword from "@/components/UserSettings/ChangePassword"
|
||||||
|
import DeleteConfirmation from "@/components/UserSettings/DeleteConfirmation"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -28,6 +42,36 @@ const formSchema = z.object({
|
|||||||
|
|
||||||
type FormData = z.infer<typeof formSchema>
|
type FormData = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
/** Compact value row; the edit affordance appears on hover, focus or touch. */
|
||||||
|
const ReadOnlyField = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string | undefined
|
||||||
|
onEdit: () => void
|
||||||
|
}) => (
|
||||||
|
<FormItem className="group flex flex-row items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">{label}</FormLabel>
|
||||||
|
<p className={cn("truncate", !value && "text-muted-foreground")}>
|
||||||
|
{value || "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Edit ${label.toLowerCase()}`}
|
||||||
|
onClick={onEdit}
|
||||||
|
className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
|
||||||
const UserInformation = () => {
|
const UserInformation = () => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
@@ -81,8 +125,16 @@ const UserInformation = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md">
|
<Card>
|
||||||
<h3 className="text-lg font-semibold py-4">User Information</h3>
|
<CardHeader>
|
||||||
|
<CardTitle>Account</CardTitle>
|
||||||
|
<CardDescription>Your profile details and sign-in</CardDescription>
|
||||||
|
<CardAction className="flex flex-col items-end gap-2">
|
||||||
|
<UserAvatar size={56} className="rounded-lg" />
|
||||||
|
{currentUser?.is_superuser && <Badge>Admin</Badge>}
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
@@ -101,17 +153,11 @@ const UserInformation = () => {
|
|||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
) : (
|
) : (
|
||||||
<FormItem>
|
<ReadOnlyField
|
||||||
<FormLabel>Full name</FormLabel>
|
label="Full name"
|
||||||
<p
|
value={field.value}
|
||||||
className={cn(
|
onEdit={toggleEditMode}
|
||||||
"py-2 truncate max-w-sm",
|
/>
|
||||||
!field.value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.value || "N/A"}
|
|
||||||
</p>
|
|
||||||
</FormItem>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -129,17 +175,17 @@ const UserInformation = () => {
|
|||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
) : (
|
) : (
|
||||||
<FormItem>
|
<ReadOnlyField
|
||||||
<FormLabel>Email</FormLabel>
|
label="Email"
|
||||||
<p className="py-2 truncate max-w-sm">{field.value}</p>
|
value={field.value}
|
||||||
</FormItem>
|
onEdit={toggleEditMode}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{editMode && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{editMode ? (
|
|
||||||
<>
|
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={mutation.isPending}
|
loading={mutation.isPending}
|
||||||
@@ -155,16 +201,22 @@ const UserInformation = () => {
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button type="button" onClick={toggleEditMode}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-wrap items-center gap-2 border-t pt-4">
|
||||||
|
<ChangePassword />
|
||||||
|
{/* A superuser deleting its own account would lock everyone out, so
|
||||||
|
that is the one thing this card does not offer them. */}
|
||||||
|
{!currentUser?.is_superuser && (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<DeleteConfirmation />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-popover p-6 shadow-e3 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
@@ -1,25 +1,11 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { motion } from "motion/react"
|
||||||
|
|
||||||
import Appearance from "@/components/UserSettings/Appearance"
|
import Appearance from "@/components/UserSettings/Appearance"
|
||||||
import ChangePassword from "@/components/UserSettings/ChangePassword"
|
|
||||||
import DeleteAccount from "@/components/UserSettings/DeleteAccount"
|
|
||||||
import RemoteAccess from "@/components/UserSettings/RemoteAccess"
|
import RemoteAccess from "@/components/UserSettings/RemoteAccess"
|
||||||
import UserInformation from "@/components/UserSettings/UserInformation"
|
import UserInformation from "@/components/UserSettings/UserInformation"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import useAuth from "@/hooks/useAuth"
|
import useAuth from "@/hooks/useAuth"
|
||||||
|
import { listStagger, slideUp } from "@/lib/motion"
|
||||||
const tabsConfig = [
|
|
||||||
{ value: "my-profile", title: "My profile", component: UserInformation },
|
|
||||||
{ value: "password", title: "Password", component: ChangePassword },
|
|
||||||
{ value: "appearance", title: "Appearance", component: Appearance },
|
|
||||||
{ value: "danger-zone", title: "Danger zone", component: DeleteAccount },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Whether this whole installation can be reached from outside is not a
|
|
||||||
// personal preference, so the tab only exists for an operator.
|
|
||||||
const superuserTabs = [
|
|
||||||
{ value: "remote-access", title: "Remote access", component: RemoteAccess },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_layout/settings")({
|
export const Route = createFileRoute("/_layout/settings")({
|
||||||
component: UserSettings,
|
component: UserSettings,
|
||||||
@@ -34,13 +20,6 @@ export const Route = createFileRoute("/_layout/settings")({
|
|||||||
|
|
||||||
function UserSettings() {
|
function UserSettings() {
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
// A superuser deleting its own account would lock everyone out.
|
|
||||||
const finalTabs = currentUser?.is_superuser
|
|
||||||
? [
|
|
||||||
...tabsConfig.filter((tab) => tab.value !== "danger-zone"),
|
|
||||||
...superuserTabs,
|
|
||||||
]
|
|
||||||
: tabsConfig
|
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return null
|
return null
|
||||||
@@ -49,26 +28,36 @@ function UserSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">User Settings</h1>
|
<h1 className="font-display text-3xl font-semibold">Settings</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
Manage your account settings and preferences
|
Manage your account settings and preferences
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="my-profile">
|
{/* Panels rather than tabs, the way the portal does it: there are few
|
||||||
<TabsList>
|
enough of these to read at once, and hiding four behind a tab bar was
|
||||||
{finalTabs.map((tab) => (
|
navigation over three cards. Changing a password and deleting an
|
||||||
<TabsTrigger key={tab.value} value={tab.value}>
|
account belong to the account, so they live in that card's footer. */}
|
||||||
{tab.title}
|
<motion.div
|
||||||
</TabsTrigger>
|
className="grid items-start gap-6 lg:grid-cols-2"
|
||||||
))}
|
variants={listStagger}
|
||||||
</TabsList>
|
initial="hidden"
|
||||||
{finalTabs.map((tab) => (
|
animate="visible"
|
||||||
<TabsContent key={tab.value} value={tab.value}>
|
>
|
||||||
<tab.component />
|
<motion.div variants={slideUp}>
|
||||||
</TabsContent>
|
<UserInformation />
|
||||||
))}
|
</motion.div>
|
||||||
</Tabs>
|
<motion.div variants={slideUp}>
|
||||||
|
<Appearance />
|
||||||
|
</motion.div>
|
||||||
|
{/* Whether this whole installation can be reached from outside is not
|
||||||
|
a personal preference, so it only exists for an operator. */}
|
||||||
|
{currentUser.is_superuser && (
|
||||||
|
<motion.div variants={slideUp}>
|
||||||
|
<RemoteAccess />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user