Compare commits

..

6 Commits

16 changed files with 544 additions and 21 deletions

View File

@ -891,13 +891,59 @@ public class Controller : ControllerBase
[HttpPut(nameof(UpdateUser))] [HttpPut(nameof(UpdateUser))]
public ActionResult<User> UpdateUser([FromBody] User updatedUser, Token authToken) public async Task<ActionResult<User>> UpdateUser([FromBody] User updatedUser, Token authToken)
{ {
var session = Db.GetSession(authToken); var session = Db.GetSession(authToken);
var sessionUser = session?.User;
if (!session.Update(updatedUser)) // Normalize: trim surrounding whitespace so a stray space can't cause a login mismatch.
updatedUser.Email = updatedUser.Email?.Trim();
// Capture the original row BEFORE the update — needed to detect an email change
// and to roll back if the set-password email can't be delivered.
var original = Db.GetUserById(updatedUser.Id);
var emailChanged = original is not null
&& !String.Equals(original.Email, updatedUser.Email, StringComparison.OrdinalIgnoreCase);
// An email change re-registers the account (resets the password + sends mail), so it is
// ADMIN-ONLY. Checked BEFORE session.Update so a non-admin's email change is never written
// (partners may still edit name/role/information of their own users — that path is unchanged).
if (emailChanged && sessionUser?.UserType != 2) // 2 == admin
return Unauthorized(); return Unauthorized();
// Reject a malformed new email before anything is written (defense alongside the frontend check).
if (emailChanged &&
!System.Text.RegularExpressions.Regex.IsMatch(updatedUser.Email ?? "", @"^[^\s@]+@[^\s@]+\.[^\s@]+$"))
return BadRequest("Invalid email format");
if (!session.Update(updatedUser)) // enforces permissions + email uniqueness; preserves parent/access
return Unauthorized();
if (emailChanged)
{
// Re-register: force the new address to set its own password via the welcome email.
// ACCEPTED LIMITATION: this is two DB writes (email via session.Update, then password
// here) plus an external email send, with no enclosing transaction (the codebase uses a
// single static connection and no transactions). A crash between the password clear and a
// successful send leaves the new email + an empty password (MustResetPassword=true). That
// state is recoverable by the admin re-applying the change, and the empty-password +
// MustResetPassword combination is the same posture as a freshly CreateUser'd account.
// We clear the password BEFORE sending (favouring the new owner's access) rather than
// after — sending first would, on a crash, lock the new owner out behind the old password.
Db.DeleteUserPassword(updatedUser); // Password="", MustResetPassword=true
var mailSuccess = await Db.SendNewUserEmail(updatedUser); // set-password mail -> NEW email
if (!mailSuccess)
{
// Full revert of the entire row to its pre-edit state — this also discards any
// Name/Role/Information edits made in the same Apply, by design (all-or-nothing).
Db.Update(original!);
return StatusCode(500, "Set-password email failed to send; email change reverted");
}
await Db.SendEmailChangedReminder(updatedUser, original!.Email); // reminder -> OLD email, best-effort
}
return updatedUser.HidePassword(); return updatedUser.HidePassword();
} }

View File

@ -61,8 +61,8 @@ public class Installation : TreeNode
public string PvStringsPerInverter { get; set; } = ""; public string PvStringsPerInverter { get; set; } = "";
public string InstallationModel { get; set; } = ""; public string InstallationModel { get; set; } = "";
public string ExternalEms { get; set; } = "No"; public string ExternalEms { get; set; } = "No";
public string CouplingType { get; set; } = "DC"; public string? CouplingType { get; set; }
public string BackupLoad { get; set; } = "None"; public string? BackupLoad { get; set; }
[Ignore] [Ignore]
public String OrderNumbers { get; set; } public String OrderNumbers { get; set; }

View File

@ -298,6 +298,41 @@ public static class UserMethods
return user.SendEmail(subject, body); return user.SendEmail(subject, body);
} }
public static Task SendEmailChangedNotice(this User user, String oldEmail)
{
var newEmail = user.Email;
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
"Die E-Mail-Adresse Ihres inesco energy Kontos wurde geändert",
$"Sehr geehrte/r {user.Name}\n" +
$"Die E-Mail-Adresse Ihres inesco energy Kontos wurde auf {newEmail} geändert. " +
$"Falls Sie diese Änderung nicht veranlasst haben, wenden Sie sich bitte an das inesco energy Support-Team."
),
"fr" => (
"L'adresse e-mail de votre compte inesco energy a été modifiée",
$"Cher/Chère {user.Name}\n" +
$"L'adresse e-mail de votre compte inesco energy a été remplacée par {newEmail}. " +
$"Si vous n'êtes pas à l'origine de cette modification, veuillez contacter l'équipe d'assistance inesco energy."
),
"it" => (
"L'indirizzo email del tuo account inesco energy è stato modificato",
$"Gentile {user.Name}\n" +
$"L'indirizzo email del tuo account inesco energy è stato cambiato in {newEmail}. " +
$"Se non hai richiesto questa modifica, contatta il team di supporto inesco energy."
),
_ => (
"Your inesco energy account email was changed",
$"Dear {user.Name}\n" +
$"The email address for your inesco energy account was changed to {newEmail}. " +
$"If you did not request this change, please contact the inesco energy Support Team."
)
};
return Mailer.Send(user.Name, oldEmail, subject, body);
}
public static Task SendTicketAssignedEmail(this User user, Ticket ticket) public static Task SendTicketAssignedEmail(this User user, Ticket ticket)
{ {
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}"; var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";

View File

@ -294,7 +294,6 @@ public static partial class Db
fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''"); fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''");
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL"); fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");
fileConnection.Execute("UPDATE Installation SET PvStringsPerInverter = '' WHERE PvStringsPerInverter IS NULL"); fileConnection.Execute("UPDATE Installation SET PvStringsPerInverter = '' WHERE PvStringsPerInverter IS NULL");
fileConnection.Execute("UPDATE Installation SET BackupLoad = 'None' WHERE BackupLoad IS NULL");
return fileConnection; return fileConnection;
//return CopyDbToMemory(fileConnection); //return CopyDbToMemory(fileConnection);
@ -517,6 +516,21 @@ public static partial class Db
} }
} }
public static async Task<Boolean> SendEmailChangedReminder(User user, String oldEmail)
{
try
{
await user.SendEmailChangedNotice(oldEmail);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Email-changed reminder failed for {oldEmail}");
Console.WriteLine(ex.ToString());
return false;
}
}
public static Boolean DeleteUserPassword(User user) public static Boolean DeleteUserPassword(User user)
{ {
user.Password = ""; user.Password = "";

View File

@ -376,7 +376,14 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
}) })
.catch(() => {}); .catch(() => {});
} }
updateInstallation(formValues, props.type); updateInstallation(
{
...formValues,
couplingType: formValues.couplingType || null,
backupLoad: formValues.backupLoad || null
},
props.type
);
}; };
const handleDelete = () => { const handleDelete = () => {
@ -912,12 +919,15 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</InputLabel> </InputLabel>
<Select <Select
name="couplingType" name="couplingType"
value={formValues.couplingType || 'DC'} value={formValues.couplingType || ''}
onChange={handleChange} onChange={handleChange}
inputProps={{ readOnly: !canEdit }} inputProps={{ readOnly: !canEdit }}
displayEmpty displayEmpty
notched notched
> >
<MenuItem value="">
<em><FormattedMessage id="notSet" defaultMessage="Not set" /></em>
</MenuItem>
<MenuItem value="AC"> <MenuItem value="AC">
<FormattedMessage id="couplingAC" defaultMessage="AC-coupled" /> <FormattedMessage id="couplingAC" defaultMessage="AC-coupled" />
</MenuItem> </MenuItem>
@ -941,12 +951,15 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</InputLabel> </InputLabel>
<Select <Select
name="backupLoad" name="backupLoad"
value={formValues.backupLoad || 'None'} value={formValues.backupLoad || ''}
onChange={handleChange} onChange={handleChange}
inputProps={{ readOnly: !canEdit }} inputProps={{ readOnly: !canEdit }}
displayEmpty displayEmpty
notched notched
> >
<MenuItem value="">
<em><FormattedMessage id="notSet" defaultMessage="Not set" /></em>
</MenuItem>
<MenuItem value="Whole"> <MenuItem value="Whole">
<FormattedMessage id="backupWhole" defaultMessage="Whole house" /> <FormattedMessage id="backupWhole" defaultMessage="Whole house" />
</MenuItem> </MenuItem>

View File

@ -82,6 +82,13 @@ function UserAccess(props: UserAccessProps) {
const [openFolder, setOpenFolder] = useState(false); const [openFolder, setOpenFolder] = useState(false);
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
// Pending access revoke awaiting confirmation (folder or direct installation)
const [revokeTarget, setRevokeTarget] = useState<{
type: 'folder' | 'installation';
id: number;
name: string;
} | null>(null);
const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]); const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]);
const [selectedInstallations, setSelectedInstallations] = useState<I_Installation[]>([]); const [selectedInstallations, setSelectedInstallations] = useState<I_Installation[]>([]);
@ -248,6 +255,16 @@ function UserAccess(props: UserAccessProps) {
}); });
}; };
const confirmRevoke = () => {
if (!revokeTarget) return;
if (revokeTarget.type === 'folder') {
handleRevokeFolder(revokeTarget.id, revokeTarget.name);
} else {
handleRevokeInstallation(revokeTarget.id);
}
setRevokeTarget(null);
};
const handleSubmit = async () => { const handleSubmit = async () => {
for (const folderName of selectedFolderNames) { for (const folderName of selectedFolderNames) {
const folder = availableFolders.find((f) => f.name === folderName); const folder = availableFolders.find((f) => f.name === folderName);
@ -405,6 +422,66 @@ function UserAccess(props: UserAccessProps) {
</Box> </Box>
</Modal> </Modal>
{/* Revoke Access Confirmation Modal */}
<Modal
open={revokeTarget !== null}
onClose={() => setRevokeTarget(null)}
aria-labelledby="revoke-confirm-modal"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 350,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Typography variant="body1" gutterBottom sx={{ fontWeight: 'bold', textAlign: 'center' }}>
<FormattedMessage
id="confirmRevokeAccess"
defaultMessage='Do you want to remove access to "{name}"?'
values={{ name: revokeTarget?.name ?? '' }}
/>
</Typography>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 10 }}>
<Button
sx={{
marginTop: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={confirmRevoke}
>
<FormattedMessage id="delete" defaultMessage="Delete" />
</Button>
<Button
sx={{
marginTop: 2,
marginLeft: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={() => setRevokeTarget(null)}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
</Modal>
<Button <Button
variant="contained" variant="contained"
onClick={handleGrantAccess} onClick={handleGrantAccess}
@ -437,7 +514,12 @@ function UserAccess(props: UserAccessProps) {
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />} {isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton> </IconButton>
{currentUser.userType === UserType.admin && ( {currentUser.userType === UserType.admin && (
<IconButton onClick={() => handleRevokeFolder(folder.id, folder.name)} edge="end"> <IconButton
onClick={() =>
setRevokeTarget({ type: 'folder', id: folder.id, name: folder.name })
}
edge="end"
>
<PersonRemoveIcon /> <PersonRemoveIcon />
</IconButton> </IconButton>
)} )}
@ -529,7 +611,16 @@ function UserAccess(props: UserAccessProps) {
sx={{ mb: isLast ? 4 : 0 }} sx={{ mb: isLast ? 4 : 0 }}
secondaryAction={ secondaryAction={
currentUser.userType === UserType.admin && ( currentUser.userType === UserType.admin && (
<IconButton onClick={() => handleRevokeInstallation(installation.id)} edge="end"> <IconButton
onClick={() =>
setRevokeTarget({
type: 'installation',
id: installation.id,
name: installation.name
})
}
edge="end"
>
<PersonRemoveIcon /> <PersonRemoveIcon />
</IconButton> </IconButton>
) )

View File

@ -501,7 +501,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
currentTab != 'installationTickets' && currentTab != 'installationTickets' &&
currentTab != 'documents' && currentTab != 'documents' &&
currentTab != 'checklist' && currentTab != 'checklist' &&
currentTab != 'onSiteChecklist' && ( currentTab != 'onsiteChecklist' && (
<Container <Container
maxWidth="xl" maxWidth="xl"
sx={{ sx={{

View File

@ -535,6 +535,43 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
/> />
</div> </div>
{device === 4 && (product === 2 || product === 5) && (
<>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
<FormattedMessage id="digitalInputs" defaultMessage="Digital Inputs" />
</Typography>
<Divider sx={{ mb: 2 }} />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, mb: 1, ml: 1 }}>
{[1, 2, 3, 4].map((n) => {
const raw = (props.values.Config as any)[`DigitalInput${n}`];
// No datapath (field absent/null) → grey; otherwise green/red
const indicator =
raw == null
? '⚪'
: String(raw).toLowerCase() === 'true'
? '🟢'
: '🔴';
return (
<Box
key={`digitalInput${n}`}
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<span style={{ fontSize: '1.1rem', lineHeight: 1 }}>
{indicator}
</span>
<Typography component="span">
<FormattedMessage
id={`digitalInput${n}`}
defaultMessage={`Digital Input ${n}`}
/>
</Typography>
</Box>
);
})}
</Box>
</>
)}
{device === 4 && ( {device === 4 && (
<> <>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}> <Typography variant="h6" sx={{ mt: 3, mb: 1 }}>

View File

@ -14,9 +14,17 @@ import {
} from '@mui/material'; } from '@mui/material';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { InnovEnergyUser } from 'src/interfaces/UserTypes'; import { InnovEnergyUser, UserType } from 'src/interfaces/UserTypes';
import User from './User'; import User from './User';
// Translation keys for each UserType, indexed by the enum value
// (client=0, partner=1, admin=2).
export const USER_ROLE_LABEL_IDS: Record<UserType, string> = {
[UserType.client]: 'roleClient',
[UserType.partner]: 'rolePartner',
[UserType.admin]: 'roleAdmin'
};
interface FlatUsersViewProps { interface FlatUsersViewProps {
users: InnovEnergyUser[]; users: InnovEnergyUser[];
fetchDataAgain: () => void; fetchDataAgain: () => void;
@ -79,6 +87,7 @@ const FlatUsersView = (props: FlatUsersViewProps) => {
<TableRow> <TableRow>
<TableCell padding="checkbox"></TableCell> <TableCell padding="checkbox"></TableCell>
<TableCell><FormattedMessage id="username" defaultMessage="Username" /></TableCell> <TableCell><FormattedMessage id="username" defaultMessage="Username" /></TableCell>
<TableCell><FormattedMessage id="role" defaultMessage="Role" /></TableCell>
<TableCell><FormattedMessage id="email" defaultMessage="Email" /></TableCell> <TableCell><FormattedMessage id="email" defaultMessage="Email" /></TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
@ -115,6 +124,21 @@ const FlatUsersView = (props: FlatUsersViewProps) => {
{user.name} {user.name}
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell>
<Typography
variant="body1"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
>
<FormattedMessage
id={
USER_ROLE_LABEL_IDS[user.userType] ?? 'roleClient'
}
/>
</Typography>
</TableCell>
<TableCell> <TableCell>
<Typography <Typography
variant="body1" variant="body1"

View File

@ -42,6 +42,12 @@ function User(props: singleUserProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [updated, setUpdated] = useState(false); const [updated, setUpdated] = useState(false);
// Tracks whether the last successful save changed the email (re-registration banner).
const [emailChanged, setEmailChanged] = useState(false);
// Specific error message key for the email-change failure cases; null => generic error.
const [errorMsgId, setErrorMsgId] = useState<string | null>(null);
// Confirmation dialog shown before an email change (resets password, emails both addresses).
const [openModalEmailChange, setOpenModalEmailChange] = useState(false);
// Active tab is kept in the URL (?tab=) alongside ?userId= so Back restores it. // Active tab is kept in the URL (?tab=) alongside ?userId= so Back restores it.
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [currentTab, setCurrentTab] = useState<string>( const [currentTab, setCurrentTab] = useState<string>(
@ -111,7 +117,13 @@ function User(props: singleUserProps) {
}); });
}; };
const handleSubmit = async (e) => { // Basic email-format guard (frontend half of the validation; backend enforces too).
const emailIsValid = (email: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((email ?? '').trim());
// Performs the actual update request. Called directly for non-email edits, or from
// the confirmation dialog when the email changed.
const doUpdate = async (didEmailChange: boolean) => {
setLoading(true); setLoading(true);
setError(false); setError(false);
@ -119,6 +131,18 @@ function User(props: singleUserProps) {
.put(`/UpdateUser`, formValues) .put(`/UpdateUser`, formValues)
.catch((err) => { .catch((err) => {
if (err.response) { if (err.response) {
if (didEmailChange && err.response.status === 500) {
// Backend reverted the change because the set-password email failed.
setErrorMsgId('userEmailChangeFailedReverted');
} else if (didEmailChange && err.response.status === 401) {
// Email already in use by another user, or not permitted (admins only).
setErrorMsgId('userEmailChangeNotAllowedOrTaken');
} else if (didEmailChange && err.response.status === 400) {
// Backend rejected a malformed email (frontend normally catches this first).
setErrorMsgId('invalidEmailFormat');
} else {
setErrorMsgId(null);
}
setError(true); setError(true);
setLoading(false); setLoading(false);
} }
@ -127,6 +151,7 @@ function User(props: singleUserProps) {
if (res) { if (res) {
props.fetchDataAgain(); props.fetchDataAgain();
setLoading(false); setLoading(false);
setEmailChanged(didEmailChange);
setUpdated(true); setUpdated(true);
setTimeout(() => { setTimeout(() => {
@ -135,6 +160,37 @@ function User(props: singleUserProps) {
} }
}; };
const handleSubmit = async (e) => {
setError(false);
// Compare against the originally-loaded email to detect a re-registration.
const didEmailChange = formValues.email !== props.current_user.email;
// Reject a malformed new email before anything is sent (defense alongside backend guard).
if (didEmailChange && !emailIsValid(formValues.email)) {
setErrorMsgId('invalidEmailFormat');
setError(true);
return;
}
// An email change resets the password and emails both addresses — confirm first.
if (didEmailChange) {
setOpenModalEmailChange(true);
return;
}
await doUpdate(false);
};
const emailChangeModalConfirm = async () => {
setOpenModalEmailChange(false);
await doUpdate(true);
};
const emailChangeModalCancel = () => {
setOpenModalEmailChange(false);
};
const handleDelete = (e) => { const handleDelete = (e) => {
setLoading(true); setLoading(true);
setError(false); setError(false);
@ -254,6 +310,94 @@ function User(props: singleUserProps) {
</Modal> </Modal>
)} )}
{openModalEmailChange && (
<Modal
open={openModalEmailChange}
onClose={emailChangeModalCancel}
aria-labelledby="email-change-modal"
aria-describedby="email-change-modal-description"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Typography
variant="body1"
gutterBottom
sx={{ fontWeight: 'bold' }}
>
<FormattedMessage
id="emailChangeConfirmTitle"
defaultMessage="Change this user's email?"
/>
</Typography>
<Typography
variant="body2"
sx={{ mt: 1, textAlign: 'center', wordBreak: 'break-all' }}
>
{props.current_user.email} {formValues.email}
</Typography>
<Typography
variant="body2"
sx={{ mt: 2, textAlign: 'center' }}
>
<FormattedMessage
id="emailChangeConfirmWarning"
defaultMessage="This resets the user's password and emails both the new and the previous address. The user must set a new password via the link sent to the new address."
/>
</Typography>
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 10
}}
>
<Button
sx={{
marginTop: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={emailChangeModalConfirm}
>
<FormattedMessage id="confirm" defaultMessage="Confirm" />
</Button>
<Button
sx={{
marginTop: 2,
marginLeft: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={emailChangeModalCancel}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
</Modal>
)}
<Grid item xs={12} md={12}> <Grid item xs={12} md={12}>
<TabsContainerWrapper> <TabsContainerWrapper>
<Tabs <Tabs
@ -405,7 +549,11 @@ function User(props: singleUserProps) {
alignItems: 'center' alignItems: 'center'
}} }}
> >
An error has occurred {errorMsgId ? (
<FormattedMessage id={errorMsgId} />
) : (
<FormattedMessage id="errorOccured" />
)}
<IconButton <IconButton
color="inherit" color="inherit"
size="small" size="small"
@ -425,7 +573,11 @@ function User(props: singleUserProps) {
alignItems: 'center' alignItems: 'center'
}} }}
> >
Successfully updated {emailChanged ? (
<FormattedMessage id="userEmailChangedSetPasswordSent" />
) : (
<FormattedMessage id="successfullyUpdated" />
)}
<IconButton <IconButton
color="inherit" color="inherit"
size="small" size="small"

View File

@ -5,6 +5,7 @@ import {
Grid, Grid,
IconButton, IconButton,
InputAdornment, InputAdornment,
MenuItem,
TextField TextField
} from '@mui/material'; } from '@mui/material';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone'; import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
@ -20,6 +21,8 @@ import { UserType } from '../../../interfaces/UserTypes';
function UsersSearch() { function UsersSearch() {
const intl = useIntl(); const intl = useIntl();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
// 'all' = no role filter; otherwise the selected UserType enum value.
const [roleFilter, setRoleFilter] = useState<UserType | 'all'>('all');
const { allUsers, fetchAllUsers } = useContext(AccessContext); const { allUsers, fetchAllUsers } = useContext(AccessContext);
const [filteredData, setFilteredData] = useState(allUsers); const [filteredData, setFilteredData] = useState(allUsers);
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
@ -37,11 +40,13 @@ function UsersSearch() {
}; };
useEffect(() => { useEffect(() => {
const filtered = allUsers.filter((item) => const filtered = allUsers.filter(
item.name.toLowerCase().includes(searchTerm.toLowerCase()) (item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
(roleFilter === 'all' || item.userType === roleFilter)
); );
setFilteredData(filtered); setFilteredData(filtered);
}, [searchTerm, allUsers]); }, [searchTerm, roleFilter, allUsers]);
const handleSubmit = () => { const handleSubmit = () => {
setOpenModal(true); setOpenModal(true);
@ -162,6 +167,36 @@ function UsersSearch() {
/> />
</FormControl> </FormControl>
</Grid> </Grid>
<Grid item xs={12} md={isMobile ? 5 : 3}>
<FormControl variant="outlined" fullWidth>
<TextField
select
label={intl.formatMessage({ id: 'role' })}
value={roleFilter}
onChange={(e) =>
setRoleFilter(
e.target.value === 'all'
? 'all'
: (Number(e.target.value) as UserType)
)
}
fullWidth
>
<MenuItem value="all">
{intl.formatMessage({ id: 'allRoles' })}
</MenuItem>
<MenuItem value={UserType.client}>
{intl.formatMessage({ id: 'roleClient' })}
</MenuItem>
<MenuItem value={UserType.partner}>
{intl.formatMessage({ id: 'rolePartner' })}
</MenuItem>
<MenuItem value={UserType.admin}>
{intl.formatMessage({ id: 'roleAdmin' })}
</MenuItem>
</TextField>
</FormControl>
</Grid>
</Grid> </Grid>
<FlatUsersView users={filteredData} fetchDataAgain={fetchDataAgain} /> <FlatUsersView users={filteredData} fetchDataAgain={fetchDataAgain} />
</> </>

View File

@ -28,8 +28,8 @@ export interface I_Installation extends I_S3Credentials {
pvStringsPerInverter: string; pvStringsPerInverter: string;
installationModel: string; installationModel: string;
externalEms: string; externalEms: string;
couplingType: string; couplingType: string | null;
backupLoad: string; backupLoad: string | null;
parentId: number; parentId: number;
s3WriteKey: string; s3WriteKey: string;

View File

@ -139,6 +139,7 @@
"backupWhole": "Komplettes Haus", "backupWhole": "Komplettes Haus",
"backupPartial": "Teilnetz", "backupPartial": "Teilnetz",
"backupNone": "nicht verwendet", "backupNone": "nicht verwendet",
"notSet": "Nicht festgelegt",
"selectModel": "Modell auswählen...", "selectModel": "Modell auswählen...",
"inverterN": "Wechselrichter {n}", "inverterN": "Wechselrichter {n}",
"clusterN": "Cluster {n}", "clusterN": "Cluster {n}",
@ -181,6 +182,7 @@
"unableToLoadData": "Daten können nicht geladen werden", "unableToLoadData": "Daten können nicht geladen werden",
"unableToRevokeAccess": "Der Zugriff konnte nicht widerrufen werden", "unableToRevokeAccess": "Der Zugriff konnte nicht widerrufen werden",
"revokedAccessFromUser": "Zugriff vom Benutzer widerrufen", "revokedAccessFromUser": "Zugriff vom Benutzer widerrufen",
"confirmRevokeAccess": "Möchten Sie den Zugriff auf \"{name}\" entfernen?",
"Show Errors": "Fehler anzeigen", "Show Errors": "Fehler anzeigen",
"Show Warnings": "Warnungen anzeigen", "Show Warnings": "Warnungen anzeigen",
"lastSeen": "Zuletzt gesehen", "lastSeen": "Zuletzt gesehen",
@ -530,6 +532,11 @@
"stopTimeMustBeLater": "Die Stoppzeit muss nach der Startzeit liegen", "stopTimeMustBeLater": "Die Stoppzeit muss nach der Startzeit liegen",
"signIn": "Anmelden", "signIn": "Anmelden",
"username": "Benutzername", "username": "Benutzername",
"role": "Rolle",
"allRoles": "Alle Rollen",
"roleClient": "Kunde",
"rolePartner": "Partner",
"roleAdmin": "Admin",
"password": "Passwort", "password": "Passwort",
"rememberMe": "Angemeldet bleiben", "rememberMe": "Angemeldet bleiben",
"login": "Anmelden", "login": "Anmelden",
@ -537,6 +544,13 @@
"forgotPasswordLink": "Passwort vergessen?", "forgotPasswordLink": "Passwort vergessen?",
"provideYourUsername": "Geben Sie Ihren Benutzernamen ein", "provideYourUsername": "Geben Sie Ihren Benutzernamen ein",
"userName": "Benutzername", "userName": "Benutzername",
"userEmailChangedSetPasswordSent": "E-Mail aktualisiert. Eine E-Mail zum Festlegen des Passworts wurde an die neue Adresse gesendet, die bisherige Adresse wurde benachrichtigt.",
"userEmailChangeFailedReverted": "Die E-Mail zum Festlegen des Passworts konnte nicht gesendet werden — die Änderung wurde rückgängig gemacht.",
"userEmailChangeNotAllowedOrTaken": "Die E-Mail konnte nicht geändert werden — sie wird bereits von einem anderen Benutzer verwendet oder ist nicht zulässig.",
"invalidEmailFormat": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"emailChangeConfirmTitle": "E-Mail-Adresse dieses Benutzers ändern?",
"emailChangeConfirmWarning": "Dadurch wird das Passwort des Benutzers zurückgesetzt und sowohl die neue als auch die bisherige Adresse benachrichtigt. Der Benutzer muss über den an die neue Adresse gesendeten Link ein neues Passwort festlegen.",
"confirm": "Bestätigen",
"resetPassword": "Passwort zurücksetzen", "resetPassword": "Passwort zurücksetzen",
"setNewPassword": "Neues Passwort setzen", "setNewPassword": "Neues Passwort setzen",
"verifyPassword": "Passwort bestätigen", "verifyPassword": "Passwort bestätigen",
@ -589,6 +603,11 @@
"relay2": "Relais 2", "relay2": "Relais 2",
"relay3": "Relais 3", "relay3": "Relais 3",
"relay4": "Relais 4", "relay4": "Relais 4",
"digitalInputs": "Digitaleingänge",
"digitalInput1": "Digitaleingang 1",
"digitalInput2": "Digitaleingang 2",
"digitalInput3": "Digitaleingang 3",
"digitalInput4": "Digitaleingang 4",
"systemSettings": "Systemeinstellungen", "systemSettings": "Systemeinstellungen",
"pvPerInverter": "PV pro Wechselrichter", "pvPerInverter": "PV pro Wechselrichter",
"pvInInverter": "PV in Wechselrichter {number}", "pvInInverter": "PV in Wechselrichter {number}",

View File

@ -121,6 +121,7 @@
"backupWhole": "Whole house", "backupWhole": "Whole house",
"backupPartial": "Partial", "backupPartial": "Partial",
"backupNone": "Not used", "backupNone": "Not used",
"notSet": "Not set",
"selectModel": "Select model...", "selectModel": "Select model...",
"inverterN": "Inverter {n}", "inverterN": "Inverter {n}",
"clusterN": "Cluster {n}", "clusterN": "Cluster {n}",
@ -163,6 +164,7 @@
"unableToLoadData": "Unable to load data", "unableToLoadData": "Unable to load data",
"unableToRevokeAccess": "Unable to revoke access", "unableToRevokeAccess": "Unable to revoke access",
"revokedAccessFromUser": "Revoked access from user: ", "revokedAccessFromUser": "Revoked access from user: ",
"confirmRevokeAccess": "Do you want to remove access to \"{name}\"?",
"Show Errors": "Show Errors", "Show Errors": "Show Errors",
"Show Warnings": "Show Warnings", "Show Warnings": "Show Warnings",
"lastSeen": "Last seen", "lastSeen": "Last seen",
@ -278,6 +280,11 @@
"stopTimeMustBeLater": "Stop time must be later than start time", "stopTimeMustBeLater": "Stop time must be later than start time",
"signIn": "Sign in", "signIn": "Sign in",
"username": "Username", "username": "Username",
"role": "Role",
"allRoles": "All roles",
"roleClient": "Client",
"rolePartner": "Partner",
"roleAdmin": "Admin",
"password": "Password", "password": "Password",
"rememberMe": "Remember me", "rememberMe": "Remember me",
"login": "Login", "login": "Login",
@ -285,6 +292,13 @@
"forgotPasswordLink": "Forgot password?", "forgotPasswordLink": "Forgot password?",
"provideYourUsername": "Provide your username", "provideYourUsername": "Provide your username",
"userName": "User Name", "userName": "User Name",
"userEmailChangedSetPasswordSent": "Email updated. A set-password email was sent to the new address, and the previous address was notified.",
"userEmailChangeFailedReverted": "Couldn't send the set-password email — the email change was reverted.",
"userEmailChangeNotAllowedOrTaken": "Couldn't change the email — it is already in use by another user or not permitted.",
"invalidEmailFormat": "Please enter a valid email address.",
"emailChangeConfirmTitle": "Change this user's email?",
"emailChangeConfirmWarning": "This resets the user's password and emails both the new and the previous address. The user must set a new password via the link sent to the new address.",
"confirm": "Confirm",
"resetPassword": "Reset Password", "resetPassword": "Reset Password",
"setNewPassword": "Set New Password", "setNewPassword": "Set New Password",
"verifyPassword": "Verify Password", "verifyPassword": "Verify Password",
@ -337,6 +351,11 @@
"relay2": "Relay 2", "relay2": "Relay 2",
"relay3": "Relay 3", "relay3": "Relay 3",
"relay4": "Relay 4", "relay4": "Relay 4",
"digitalInputs": "Digital Inputs",
"digitalInput1": "Digital Input 1",
"digitalInput2": "Digital Input 2",
"digitalInput3": "Digital Input 3",
"digitalInput4": "Digital Input 4",
"systemSettings": "System Settings", "systemSettings": "System Settings",
"pvPerInverter": "PV per Inverter", "pvPerInverter": "PV per Inverter",
"pvInInverter": "PV in Inverter {number}", "pvInInverter": "PV in Inverter {number}",

View File

@ -133,6 +133,7 @@
"backupWhole": "Maison entière", "backupWhole": "Maison entière",
"backupPartial": "Partiel", "backupPartial": "Partiel",
"backupNone": "Non utilisé", "backupNone": "Non utilisé",
"notSet": "Non défini",
"selectModel": "Sélectionner le modèle...", "selectModel": "Sélectionner le modèle...",
"inverterN": "Onduleur {n}", "inverterN": "Onduleur {n}",
"clusterN": "Cluster {n}", "clusterN": "Cluster {n}",
@ -175,6 +176,7 @@
"unableToLoadData": "Impossible de charger les données", "unableToLoadData": "Impossible de charger les données",
"unableToRevokeAccess": "Impossible de révoquer l'accès", "unableToRevokeAccess": "Impossible de révoquer l'accès",
"revokedAccessFromUser": "Accès révoqué de l'utilisateur", "revokedAccessFromUser": "Accès révoqué de l'utilisateur",
"confirmRevokeAccess": "Voulez-vous retirer l'accès à « {name} » ?",
"Show Errors": "Afficher les erreurs", "Show Errors": "Afficher les erreurs",
"Show Warnings": "Afficher les avertissements", "Show Warnings": "Afficher les avertissements",
"lastSeen": "Dernière connexion", "lastSeen": "Dernière connexion",
@ -530,6 +532,11 @@
"stopTimeMustBeLater": "L'heure d'arrêt doit être postérieure à l'heure de début", "stopTimeMustBeLater": "L'heure d'arrêt doit être postérieure à l'heure de début",
"signIn": "Se connecter", "signIn": "Se connecter",
"username": "Nom d'utilisateur", "username": "Nom d'utilisateur",
"role": "Rôle",
"allRoles": "Tous les rôles",
"roleClient": "Client",
"rolePartner": "Partenaire",
"roleAdmin": "Admin",
"password": "Mot de passe", "password": "Mot de passe",
"rememberMe": "Se souvenir de moi", "rememberMe": "Se souvenir de moi",
"login": "Connexion", "login": "Connexion",
@ -537,6 +544,13 @@
"forgotPasswordLink": "Mot de passe oublié ?", "forgotPasswordLink": "Mot de passe oublié ?",
"provideYourUsername": "Entrez votre nom d'utilisateur", "provideYourUsername": "Entrez votre nom d'utilisateur",
"userName": "Nom d'utilisateur", "userName": "Nom d'utilisateur",
"userEmailChangedSetPasswordSent": "E-mail mis à jour. Un e-mail de définition du mot de passe a été envoyé à la nouvelle adresse, et l'ancienne adresse a été avertie.",
"userEmailChangeFailedReverted": "Impossible d'envoyer l'e-mail de définition du mot de passe — la modification a été annulée.",
"userEmailChangeNotAllowedOrTaken": "Impossible de modifier l'e-mail — il est déjà utilisé par un autre utilisateur ou n'est pas autorisé.",
"invalidEmailFormat": "Veuillez saisir une adresse e-mail valide.",
"emailChangeConfirmTitle": "Modifier l'e-mail de cet utilisateur ?",
"emailChangeConfirmWarning": "Cela réinitialise le mot de passe de l'utilisateur et avertit à la fois la nouvelle et l'ancienne adresse. L'utilisateur doit définir un nouveau mot de passe via le lien envoyé à la nouvelle adresse.",
"confirm": "Confirmer",
"resetPassword": "Réinitialiser le mot de passe", "resetPassword": "Réinitialiser le mot de passe",
"setNewPassword": "Définir un nouveau mot de passe", "setNewPassword": "Définir un nouveau mot de passe",
"verifyPassword": "Vérifier le mot de passe", "verifyPassword": "Vérifier le mot de passe",
@ -589,6 +603,11 @@
"relay2": "Relais 2", "relay2": "Relais 2",
"relay3": "Relais 3", "relay3": "Relais 3",
"relay4": "Relais 4", "relay4": "Relais 4",
"digitalInputs": "Entrées numériques",
"digitalInput1": "Entrée numérique 1",
"digitalInput2": "Entrée numérique 2",
"digitalInput3": "Entrée numérique 3",
"digitalInput4": "Entrée numérique 4",
"systemSettings": "Paramètres système", "systemSettings": "Paramètres système",
"pvPerInverter": "PV par onduleur", "pvPerInverter": "PV par onduleur",
"pvInInverter": "PV dans l'onduleur {number}", "pvInInverter": "PV dans l'onduleur {number}",

View File

@ -121,6 +121,7 @@
"backupWhole": "Intera casa", "backupWhole": "Intera casa",
"backupPartial": "Parziale", "backupPartial": "Parziale",
"backupNone": "Non utilizzato", "backupNone": "Non utilizzato",
"notSet": "Non impostato",
"selectModel": "Seleziona modello...", "selectModel": "Seleziona modello...",
"inverterN": "Inverter {n}", "inverterN": "Inverter {n}",
"clusterN": "Cluster {n}", "clusterN": "Cluster {n}",
@ -163,6 +164,7 @@
"unableToLoadData": "Impossibile caricare i dati", "unableToLoadData": "Impossibile caricare i dati",
"unableToRevokeAccess": "Impossibile revocare l'accesso", "unableToRevokeAccess": "Impossibile revocare l'accesso",
"revokedAccessFromUser": "Accesso revocato all'utente: ", "revokedAccessFromUser": "Accesso revocato all'utente: ",
"confirmRevokeAccess": "Vuoi rimuovere l'accesso a \"{name}\"?",
"alarms": "Allarmi", "alarms": "Allarmi",
"overview": "Panoramica", "overview": "Panoramica",
"manage": "Gestione accessi", "manage": "Gestione accessi",
@ -530,6 +532,11 @@
"stopTimeMustBeLater": "L'ora di fine deve essere successiva all'ora di inizio", "stopTimeMustBeLater": "L'ora di fine deve essere successiva all'ora di inizio",
"signIn": "Accedi", "signIn": "Accedi",
"username": "Nome utente", "username": "Nome utente",
"role": "Ruolo",
"allRoles": "Tutti i ruoli",
"roleClient": "Cliente",
"rolePartner": "Partner",
"roleAdmin": "Admin",
"password": "Password", "password": "Password",
"rememberMe": "Ricordami", "rememberMe": "Ricordami",
"login": "Accedi", "login": "Accedi",
@ -537,6 +544,13 @@
"forgotPasswordLink": "Password dimenticata?", "forgotPasswordLink": "Password dimenticata?",
"provideYourUsername": "Inserisci il tuo nome utente", "provideYourUsername": "Inserisci il tuo nome utente",
"userName": "Nome utente", "userName": "Nome utente",
"userEmailChangedSetPasswordSent": "Email aggiornata. Un'email per impostare la password è stata inviata al nuovo indirizzo e il precedente è stato avvisato.",
"userEmailChangeFailedReverted": "Impossibile inviare l'email per impostare la password — la modifica è stata annullata.",
"userEmailChangeNotAllowedOrTaken": "Impossibile modificare l'email — è già utilizzata da un altro utente o non è consentita.",
"invalidEmailFormat": "Inserisci un indirizzo email valido.",
"emailChangeConfirmTitle": "Modificare l'email di questo utente?",
"emailChangeConfirmWarning": "Questo reimposta la password dell'utente e avvisa sia il nuovo indirizzo sia quello precedente. L'utente deve impostare una nuova password tramite il link inviato al nuovo indirizzo.",
"confirm": "Conferma",
"resetPassword": "Reimposta password", "resetPassword": "Reimposta password",
"setNewPassword": "Imposta nuova password", "setNewPassword": "Imposta nuova password",
"verifyPassword": "Verifica password", "verifyPassword": "Verifica password",
@ -589,6 +603,11 @@
"relay2": "Relè 2", "relay2": "Relè 2",
"relay3": "Relè 3", "relay3": "Relè 3",
"relay4": "Relè 4", "relay4": "Relè 4",
"digitalInputs": "Ingressi digitali",
"digitalInput1": "Ingresso digitale 1",
"digitalInput2": "Ingresso digitale 2",
"digitalInput3": "Ingresso digitale 3",
"digitalInput4": "Ingresso digitale 4",
"systemSettings": "Impostazioni di sistema", "systemSettings": "Impostazioni di sistema",
"pvPerInverter": "PV per inverter", "pvPerInverter": "PV per inverter",
"pvInInverter": "PV nell'inverter {number}", "pvInInverter": "PV nell'inverter {number}",