599 lines
25 KiB
TypeScript
599 lines
25 KiB
TypeScript
import type { FormEvent } from 'react';
|
||
import { useMemo, useState } from 'react';
|
||
import { ClipboardList, FileCheck, KeyRound, Loader2, UserCircle } from 'lucide-react';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { ModuleHeader } from '@/components/ui/module-header';
|
||
import { NativeSelect } from '@/components/ui/native-select';
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table';
|
||
import { useAuth } from '@/contexts/useAuth';
|
||
import { useIamCapabilities } from '@/business/iam-capabilities/hooks';
|
||
import { getAuthRoleLabel } from '@/business/auth/selectors';
|
||
import { hasPermission } from '@/business/auth/permissions';
|
||
import { changePassword, updateOwnProfile, updateOrganization } from '@/business/profile/api';
|
||
import {
|
||
buildProfileDocumentAcknowledgmentRows,
|
||
buildProfileQuizResultRows,
|
||
} from '@/business/profile/selectors';
|
||
import { getErrorMessage } from '@/shared/errors/errorMessages';
|
||
import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
|
||
import { ImageUpload } from '@/components/common/ImageUpload';
|
||
import { useCurrentPersonalityResultHistory } from '@/business/personality/queryHooks';
|
||
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
||
import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
|
||
import { canZoneCheckIn } from '@/business/zone-checkin/selectors';
|
||
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
|
||
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
|
||
|
||
interface StatusMessage {
|
||
readonly type: 'success' | 'error';
|
||
readonly text: string;
|
||
}
|
||
|
||
const formControlClassName =
|
||
'border-slate-600 bg-slate-950/80 text-slate-100 placeholder:text-slate-500 focus-visible:ring-sky-400 focus-visible:ring-offset-slate-950';
|
||
|
||
const profileCardClassName =
|
||
'border-slate-600/70 bg-slate-900/80 shadow-lg shadow-black/20';
|
||
|
||
const formPanelClassName =
|
||
'rounded-lg border border-slate-600/80 bg-slate-950/45 p-4';
|
||
|
||
function StatusBanner({ status }: { status: StatusMessage | null }) {
|
||
if (!status) {
|
||
return null;
|
||
}
|
||
|
||
const className =
|
||
status.type === 'success'
|
||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
|
||
: 'border-red-500/30 bg-red-500/10 text-red-200';
|
||
|
||
return (
|
||
<div className={`rounded-lg border px-3 py-2 text-sm ${className}`}>{status.text}</div>
|
||
);
|
||
}
|
||
|
||
function ReadOnlyField({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="rounded-lg border border-slate-700/70 bg-slate-950/55 px-3 py-2">
|
||
<p className="text-xs font-medium text-slate-400">{label}</p>
|
||
<p className="mt-1 text-sm font-semibold text-slate-100">{value || '—'}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ProfilePage() {
|
||
const { user, profile, refreshUser } = useAuth();
|
||
const capabilitiesQuery = useIamCapabilities();
|
||
const isExternalProfileUser =
|
||
user?.app_role?.name === 'student' || user?.app_role?.name === 'guardian';
|
||
const canShowQuizResults = Boolean(user) && !isExternalProfileUser;
|
||
const safetyQuizStatus = useMySafetyQuizStatus(undefined, canShowQuizResults);
|
||
const personalityHistoryStatus = useCurrentPersonalityResultHistory(canShowQuizResults);
|
||
const canUseZoneCheckin = canZoneCheckIn(user);
|
||
const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canShowQuizResults && canUseZoneCheckin });
|
||
const canReadAcknowledgedDocuments = hasPermission(user, 'ACK_POLICY');
|
||
const policyAcknowledgmentsStatus = usePolicyAcknowledgments(canReadAcknowledgedDocuments);
|
||
const handbookPoliciesStatus = usePolicies(canReadAcknowledgedDocuments);
|
||
const safetyProtocolsStatus = useSafetyProtocols(canReadAcknowledgedDocuments);
|
||
|
||
const [namePrefix, setNamePrefix] = useState<string>(user?.name_prefix ?? '');
|
||
const [firstName, setFirstName] = useState<string>(user?.firstName ?? '');
|
||
const [lastName, setLastName] = useState<string>(user?.lastName ?? '');
|
||
const [phoneNumber, setPhoneNumber] = useState<string>(user?.phoneNumber ?? '');
|
||
const [email, setEmail] = useState<string>(user?.email ?? '');
|
||
const [profileSaving, setProfileSaving] = useState(false);
|
||
const [profileStatus, setProfileStatus] = useState<StatusMessage | null>(null);
|
||
|
||
const [currentPassword, setCurrentPassword] = useState('');
|
||
const [newPassword, setNewPassword] = useState('');
|
||
const [confirmPassword, setConfirmPassword] = useState('');
|
||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||
const [passwordStatus, setPasswordStatus] = useState<StatusMessage | null>(null);
|
||
|
||
const [avatar, setAvatar] = useState<string | null>(user?.avatar ?? null);
|
||
const [avatarStatus, setAvatarStatus] = useState<StatusMessage | null>(null);
|
||
const [organizationName, setOrganizationName] = useState(user?.organizations?.name ?? '');
|
||
const [organizationLogo, setOrganizationLogo] = useState<string | null>(
|
||
user?.organizations?.logo ?? null,
|
||
);
|
||
const [organizationSaving, setOrganizationSaving] = useState(false);
|
||
const [organizationStatus, setOrganizationStatus] = useState<StatusMessage | null>(null);
|
||
|
||
const roleLabel = profile ? getAuthRoleLabel(profile.role) : '—';
|
||
const canEditOwnOrganization = capabilitiesQuery.data?.canEditOwnOrganization === true
|
||
&& Boolean(user?.organizations?.id);
|
||
|
||
const tenantChain = useMemo(() => {
|
||
const parts = [
|
||
user?.organizations?.name,
|
||
user?.school?.name,
|
||
user?.campus?.name ?? user?.campus?.code,
|
||
user?.classRoom?.name,
|
||
].filter((part): part is string => Boolean(part));
|
||
return parts.length > 0 ? parts.join(' › ') : 'Platform';
|
||
}, [user]);
|
||
|
||
const quizResultRows = useMemo(
|
||
() => buildProfileQuizResultRows(
|
||
safetyQuizStatus.data?.result ?? null,
|
||
personalityHistoryStatus.data ?? [],
|
||
zoneCheckinStatus.isCheckedInToday && zoneCheckinStatus.todayZone
|
||
? {
|
||
date: zoneCheckinStatus.todayDate ?? new Date().toISOString().slice(0, 10),
|
||
zone: zoneCheckinStatus.todayZone,
|
||
isCheckedInToday: true,
|
||
}
|
||
: null,
|
||
canUseZoneCheckin,
|
||
),
|
||
[
|
||
personalityHistoryStatus.data,
|
||
safetyQuizStatus.data?.result,
|
||
zoneCheckinStatus.isCheckedInToday,
|
||
zoneCheckinStatus.todayDate,
|
||
zoneCheckinStatus.todayZone,
|
||
canUseZoneCheckin,
|
||
],
|
||
);
|
||
const documentAcknowledgmentRows = useMemo(
|
||
() => buildProfileDocumentAcknowledgmentRows(
|
||
handbookPoliciesStatus.data ?? [],
|
||
safetyProtocolsStatus.data ?? [],
|
||
policyAcknowledgmentsStatus.data ?? [],
|
||
),
|
||
[
|
||
handbookPoliciesStatus.data,
|
||
policyAcknowledgmentsStatus.data,
|
||
safetyProtocolsStatus.data,
|
||
],
|
||
);
|
||
|
||
if (!user) {
|
||
return null;
|
||
}
|
||
|
||
async function handleProfileSubmit(event: FormEvent) {
|
||
event.preventDefault();
|
||
setProfileStatus(null);
|
||
setProfileSaving(true);
|
||
try {
|
||
await updateOwnProfile({
|
||
name_prefix: namePrefix === '' ? null : namePrefix,
|
||
firstName: firstName.trim(),
|
||
lastName: lastName.trim(),
|
||
phoneNumber: phoneNumber.trim() || null,
|
||
email: email.trim(),
|
||
});
|
||
await refreshUser();
|
||
setProfileStatus({ type: 'success', text: 'Profile updated.' });
|
||
} catch (error) {
|
||
setProfileStatus({ type: 'error', text: getErrorMessage(error, 'Could not update profile') });
|
||
} finally {
|
||
setProfileSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleAvatarChange(privateUrl: string | null) {
|
||
setAvatar(privateUrl);
|
||
setAvatarStatus(null);
|
||
try {
|
||
await updateOwnProfile({ avatar: privateUrl });
|
||
await refreshUser();
|
||
setAvatarStatus({
|
||
type: 'success',
|
||
text: privateUrl ? 'Avatar updated.' : 'Avatar removed.',
|
||
});
|
||
} catch (error) {
|
||
setAvatarStatus({ type: 'error', text: getErrorMessage(error, 'Could not update avatar') });
|
||
}
|
||
}
|
||
|
||
async function handlePasswordSubmit(event: FormEvent) {
|
||
event.preventDefault();
|
||
setPasswordStatus(null);
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
setPasswordStatus({ type: 'error', text: 'New password and confirmation do not match.' });
|
||
return;
|
||
}
|
||
|
||
setPasswordSaving(true);
|
||
try {
|
||
await changePassword(currentPassword, newPassword);
|
||
setCurrentPassword('');
|
||
setNewPassword('');
|
||
setConfirmPassword('');
|
||
setPasswordStatus({ type: 'success', text: 'Password updated.' });
|
||
} catch (error) {
|
||
setPasswordStatus({ type: 'error', text: getErrorMessage(error, 'Could not update password') });
|
||
} finally {
|
||
setPasswordSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleOrganizationSubmit(event: FormEvent) {
|
||
event.preventDefault();
|
||
const organizationId = user?.organizations?.id;
|
||
if (!organizationId) return;
|
||
setOrganizationStatus(null);
|
||
setOrganizationSaving(true);
|
||
try {
|
||
await updateOrganization(organizationId, {
|
||
name: organizationName.trim(),
|
||
logo: organizationLogo ?? undefined,
|
||
});
|
||
await refreshUser();
|
||
setOrganizationStatus({ type: 'success', text: 'Organization profile updated.' });
|
||
} catch (error) {
|
||
setOrganizationStatus({
|
||
type: 'error',
|
||
text: getErrorMessage(error, 'Could not update organization profile'),
|
||
});
|
||
} finally {
|
||
setOrganizationSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 p-4 md:p-6">
|
||
<ModuleHeader
|
||
title="My Profile"
|
||
description="Manage your name, email, and password."
|
||
icon={UserCircle}
|
||
iconClassName="bg-gradient-to-br from-violet-500 to-amber-400"
|
||
/>
|
||
|
||
<div className="space-y-6">
|
||
<Card className={profileCardClassName}>
|
||
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
||
<CardTitle className="text-base text-slate-50">Account details</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 md:px-5">
|
||
<div className="space-y-5 pt-6">
|
||
<div className={`${formPanelClassName} space-y-4`}>
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-100">Profile identity</p>
|
||
<p className="mt-1 text-xs text-slate-300">
|
||
Your visible name, contact details, and avatar.
|
||
</p>
|
||
</div>
|
||
<div className="grid gap-5 lg:grid-cols-[auto_1fr]">
|
||
<ImageUpload
|
||
value={avatar}
|
||
onChange={handleAvatarChange}
|
||
table="users"
|
||
field="avatar"
|
||
label="Avatar"
|
||
shape="square"
|
||
previewSize="lg"
|
||
/>
|
||
<div className="grid content-start gap-3 sm:grid-cols-2">
|
||
<ReadOnlyField label="Role" value={roleLabel} />
|
||
<ReadOnlyField label="Scope" value={tenantChain} />
|
||
</div>
|
||
</div>
|
||
<StatusBanner status={avatarStatus} />
|
||
</div>
|
||
|
||
<form className="space-y-5" onSubmit={handleProfileSubmit}>
|
||
<div className={`${formPanelClassName} space-y-4`}>
|
||
<div className="grid gap-4 md:grid-cols-4">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="name_prefix" className="text-slate-100">Title</Label>
|
||
<NativeSelect
|
||
id="name_prefix"
|
||
value={namePrefix}
|
||
className={formControlClassName}
|
||
onChange={(event) => setNamePrefix(event.target.value)}
|
||
>
|
||
<option value="">None</option>
|
||
{USER_NAME_PREFIX_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</NativeSelect>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="firstName" className="text-slate-100">First name</Label>
|
||
<Input
|
||
id="firstName"
|
||
value={firstName}
|
||
className={formControlClassName}
|
||
onChange={(event) => setFirstName(event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="lastName" className="text-slate-100">Last name</Label>
|
||
<Input
|
||
id="lastName"
|
||
value={lastName}
|
||
className={formControlClassName}
|
||
onChange={(event) => setLastName(event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="phoneNumber" className="text-slate-100">Phone number</Label>
|
||
<Input
|
||
id="phoneNumber"
|
||
type="tel"
|
||
value={phoneNumber}
|
||
className={formControlClassName}
|
||
onChange={(event) => setPhoneNumber(event.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="email" className="text-slate-100">Email</Label>
|
||
<Input
|
||
id="email"
|
||
type="email"
|
||
value={email}
|
||
className={formControlClassName}
|
||
onChange={(event) => setEmail(event.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<StatusBanner status={profileStatus} />
|
||
|
||
<div className="flex justify-end">
|
||
<Button type="submit" disabled={profileSaving}>
|
||
{profileSaving && <Loader2 size={16} className="mr-2 animate-spin" />}
|
||
Save changes
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{canEditOwnOrganization && (
|
||
<Card className={profileCardClassName}>
|
||
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
||
<CardTitle className="text-base text-slate-50">Organization profile</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 md:px-5">
|
||
<form className="space-y-5 pt-6" onSubmit={handleOrganizationSubmit}>
|
||
<div className={`${formPanelClassName} space-y-4`}>
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-100">Organization identity</p>
|
||
<p className="mt-1 text-xs text-slate-300">
|
||
The name and logo shown for your organization.
|
||
</p>
|
||
</div>
|
||
<ImageUpload
|
||
value={organizationLogo}
|
||
onChange={setOrganizationLogo}
|
||
table="organizations"
|
||
field="logo"
|
||
label="Logo"
|
||
/>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="organizationName" className="text-slate-100">Organization name</Label>
|
||
<Input
|
||
id="organizationName"
|
||
value={organizationName}
|
||
className={formControlClassName}
|
||
onChange={(event) => setOrganizationName(event.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<StatusBanner status={organizationStatus} />
|
||
|
||
<div className="flex justify-end">
|
||
<Button type="submit" disabled={organizationSaving}>
|
||
{organizationSaving && <Loader2 size={16} className="mr-2 animate-spin" />}
|
||
Save organization
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
{canShowQuizResults && (
|
||
<Card className={profileCardClassName}>
|
||
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
||
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
||
<ClipboardList size={16} />
|
||
Quiz results
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 md:px-5">
|
||
<div className={`${formPanelClassName} mt-6`}>
|
||
{safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading || zoneCheckinStatus.isLoading ? (
|
||
<p className="text-sm text-slate-300">Loading quiz results...</p>
|
||
) : (
|
||
<div className="overflow-hidden rounded-lg border border-slate-700/70">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="border-slate-700/70 bg-slate-950/60 hover:bg-slate-950/60">
|
||
<TableHead className="h-auto p-3 text-slate-400">Quiz</TableHead>
|
||
<TableHead className="h-auto p-3 text-slate-400">Category</TableHead>
|
||
<TableHead className="h-auto p-3 text-slate-400">Result</TableHead>
|
||
<TableHead className="h-auto p-3 text-slate-400">Completed</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{quizResultRows.map((result) => (
|
||
<TableRow
|
||
key={result.id}
|
||
className="border-slate-800/80 hover:bg-slate-800/20"
|
||
>
|
||
<TableCell className="p-3">
|
||
<p className="font-semibold text-slate-100">{result.quiz}</p>
|
||
</TableCell>
|
||
<TableCell className="p-3 text-slate-300">{result.category}</TableCell>
|
||
<TableCell className={`p-3 font-semibold ${
|
||
result.status === 'complete' ? 'text-slate-100' : 'text-amber-300'
|
||
}`}>
|
||
{result.result}
|
||
</TableCell>
|
||
<TableCell className="p-3 text-slate-300">{result.completed}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{canReadAcknowledgedDocuments && (
|
||
<Card className={profileCardClassName}>
|
||
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
||
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
||
<FileCheck size={16} />
|
||
Document acknowledgments
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 md:px-5">
|
||
<div className={`${formPanelClassName} mt-6`}>
|
||
{(
|
||
policyAcknowledgmentsStatus.isLoading
|
||
|| handbookPoliciesStatus.isLoading
|
||
|| safetyProtocolsStatus.isLoading
|
||
) ? (
|
||
<p className="text-sm text-slate-300">Loading document acknowledgments...</p>
|
||
) : documentAcknowledgmentRows.length === 0 ? (
|
||
<p className="text-sm text-slate-300">
|
||
No current documents are assigned yet.
|
||
</p>
|
||
) : (
|
||
<div className="overflow-hidden rounded-lg border border-slate-700/70">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="border-slate-700/70 bg-slate-950/60 hover:bg-slate-950/60">
|
||
<TableHead className="h-auto p-3 text-slate-400">Document</TableHead>
|
||
<TableHead className="h-auto p-3 text-slate-400">Category</TableHead>
|
||
<TableHead className="h-auto p-3 text-slate-400">Version</TableHead>
|
||
<TableHead className="h-auto p-3 text-slate-400">Acknowledged on</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{documentAcknowledgmentRows.map((document) => (
|
||
<TableRow
|
||
key={document.id}
|
||
className="border-slate-800/80 hover:bg-slate-800/20"
|
||
>
|
||
<TableCell className="p-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<p className="font-semibold text-slate-100">{document.title}</p>
|
||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||
document.status === 'acknowledged'
|
||
? 'bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/30'
|
||
: 'bg-red-500/15 text-red-200 ring-1 ring-red-400/30'
|
||
}`}>
|
||
{document.status === 'acknowledged'
|
||
? 'Acknowledged'
|
||
: 'Not acknowledged'}
|
||
</span>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="p-3 text-slate-300">{document.category}</TableCell>
|
||
<TableCell className="p-3 text-slate-300">{document.version}</TableCell>
|
||
<TableCell className="p-3 text-slate-300">
|
||
{document.acknowledgedAt
|
||
? new Date(document.acknowledgedAt).toLocaleDateString()
|
||
: '—'}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<Card className={profileCardClassName}>
|
||
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
||
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
||
<KeyRound size={16} />
|
||
Change password
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 md:px-5">
|
||
<form className="space-y-5 pt-6" onSubmit={handlePasswordSubmit}>
|
||
<div className={`${formPanelClassName} space-y-4`}>
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-100">Password credentials</p>
|
||
<p className="mt-1 text-xs text-slate-300">
|
||
Use your current password before setting a new one.
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="currentPassword" className="text-slate-100">Current password</Label>
|
||
<Input
|
||
id="currentPassword"
|
||
type="password"
|
||
autoComplete="current-password"
|
||
value={currentPassword}
|
||
className={formControlClassName}
|
||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="newPassword" className="text-slate-100">New password</Label>
|
||
<Input
|
||
id="newPassword"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={newPassword}
|
||
className={formControlClassName}
|
||
onChange={(event) => setNewPassword(event.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="confirmPassword" className="text-slate-100">Confirm new password</Label>
|
||
<Input
|
||
id="confirmPassword"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={confirmPassword}
|
||
className={formControlClassName}
|
||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<StatusBanner status={passwordStatus} />
|
||
|
||
<div className="flex justify-end">
|
||
<Button type="submit" disabled={passwordSaving}>
|
||
{passwordSaving && <Loader2 size={16} className="mr-2 animate-spin" />}
|
||
Update password
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|