LCOV - code coverage report
Current view: top level - pkg/users - account-details.js Coverage Total Hit
Test: cockpit Lines: 82.1 % 363 298
Test Date: 2026-07-03 14:25:32

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2020 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6           19 : import React, { useState, useEffect, useRef, useMemo } from 'react';
       7              : 
       8              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
       9              : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
      10              : import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
      11              : import { EmptyState, EmptyStateActions, EmptyStateFooter, EmptyStateVariant } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
      12              : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
      13              : import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
      14              : import { Label, LabelGroup } from "@patternfly/react-core/dist/esm/components/Label/index.js";
      15              : import { Page, PageBreadcrumb, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
      16              : import { Gallery } from "@patternfly/react-core/dist/esm/layouts/Gallery/index.js";
      17              : import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
      18              : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
      19              : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
      20              : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
      21              : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
      22              : import { ExclamationCircleIcon, HelpIcon, UndoIcon } from '@patternfly/react-icons';
      23              : 
      24           19 : import cockpit from 'cockpit';
      25              : import { superuser } from "superuser";
      26              : import * as timeformat from "timeformat";
      27              : import { apply_modal_dialog } from "cockpit-components-dialog.jsx";
      28              : import { MultiTypeaheadSelect } from "cockpit-components-multi-typeahead-select";
      29              : 
      30              : import { FormHelper } from "cockpit-components-form-helper";
      31              : import { show_unexpected_error } from "./dialog-utils.js";
      32              : import { delete_account_dialog } from "./delete-account-dialog.js";
      33              : import { account_expiration_dialog, password_expiration_dialog } from "./expiration-dialogs.js";
      34              : import { account_shell_dialog } from "./shell-dialog.js";
      35              : import { set_password_dialog, reset_password_dialog } from "./password-dialogs.js";
      36              : import { AccountLogs } from "./account-logs-panel.jsx";
      37              : import { AuthorizedKeys } from "./authorized-keys-panel.js";
      38              : import { get_locked } from "./utils.js";
      39              : 
      40           19 : const _ = cockpit.gettext;
      41              : 
      42           11 : function get_expire(name) {
      43           11 :     function parse_expire(data) {
      44           11 :         let account_expiration = '';
      45           11 :         let account_date = null;
      46              : 
      47           11 :         let password_expiration = '';
      48           11 :         let password_days = null;
      49              : 
      50           11 :         data.split('\n').forEach(line => {
      51           11 :             const fields = line.split(': ');
      52           11 :             if (fields[0] && fields[0].indexOf("Password expires") === 0) {
      53            3 :                 if (fields[1].indexOf("never") === 0) {
      54            3 :                     password_expiration = _("Never expire password");
      55            2 :                 } else if (fields[1].indexOf("password must be changed") === 0) {
      56            4 :                     password_expiration = _("Password must be changed");
      57            3 :                 } else {
      58           10 :                     password_expiration = cockpit.format(_("Require password change on $0"), timeformat.date(new Date(fields[1])));
      59           10 :                 }
      60           11 :             } else if (fields[0] && fields[0].indexOf("Account expires") === 0) {
      61           11 :                 if (fields[1].indexOf("never") === 0) {
      62           11 :                     account_expiration = _("Never expire account");
      63            2 :                 } else {
      64            2 :                     account_date = new Date(fields[1] + " 12:00:00 UTC");
      65            2 :                     account_expiration = cockpit.format(_("Expire account on $0"), timeformat.date(new Date(fields[1])));
      66            2 :                 }
      67           11 :             } else if (fields[0] && fields[0].indexOf("Maximum number of days between password change") === 0) {
      68           11 :                 password_days = fields[1];
      69           11 :             }
      70           11 :         });
      71              : 
      72              :         // 99999 traditionally meant "never" but the modern value for
      73              :         // that is -1.
      74              :         //
      75              :         // Modern versions of "chage" will not treat 99999 any special
      76              :         // and will output bogus expiration dates somewhere in 2300.
      77              :         // But let's recognize both, since old accounts will still use
      78              :         // 99999, and /etc/login.def also still uses 99999 as the
      79              :         // default for new accounts.
      80              :         //
      81              :         // Passord expiration is being removed completely from
      82              :         // shadow-utils, and we probably should continue to recognize
      83              :         // 99999 as "never" until we stop supporting password expiry
      84              :         // entirely.
      85              : 
      86            3 :         if (parseInt(password_days) >= 99999 || parseInt(password_days) < 0) {
      87           10 :             password_days = null;
      88           10 :             password_expiration = _("Never expire password");
      89           10 :         }
      90              : 
      91           11 :         return {
      92           11 :             account_text: account_expiration,
      93           11 :             account_date,
      94           11 :             password_text: password_expiration,
      95           11 :             password_days
      96           11 :         };
      97           11 :     }
      98              : 
      99           11 :     return cockpit.spawn(["chage", "-l", name],
     100           11 :                          { environ: ["LC_ALL=C"], err: "message", superuser: "try" })
     101            0 :             .catch(() => "")
     102           11 :             .then(parse_expire);
     103           11 : }
     104              : 
     105            0 : export function AccountNotFound() {
     106            0 :     return (
     107            0 :         <EmptyState headingLevel="h1" icon={ExclamationCircleIcon} titleText={_("Account not available or cannot be edited.")} variant={EmptyStateVariant.sm} id="account-failure">
     108            0 :             <EmptyStateFooter>
     109            0 :                 <EmptyStateActions>
     110            0 :                     <Breadcrumb>
     111            0 :                         <BreadcrumbItem to="#/">{_("Back to accounts")}</BreadcrumbItem>
     112            0 :                     </Breadcrumb>
     113            0 :                 </EmptyStateActions>
     114            0 :             </EmptyStateFooter>
     115            0 :         </EmptyState>
     116              :     );
     117            0 : }
     118              : 
     119           11 : export function AccountDetails({ account, groups, isLoading, current_user, shells }) {
     120           11 :     const [expiration, setExpiration] = useState(null);
     121              : 
     122           11 :     const user = useMemo(() => account.name, [account.name]);
     123           11 :     const [isLocked, setIsLocked] = useState(account.isLocked);
     124              : 
     125           11 :     const [editedRealName, setEditedRealName] = useState(null);
     126           11 :     const [realNameError, setRealNameError] = useState("");
     127           11 :     const [comittingRealName, setCommittingRealName] = useState(false);
     128           11 :     const [disableLockedEdit, setDisableLockedEdit] = useState(false);
     129              : 
     130           11 :     useEffect(() => {
     131           11 :         get_expire(account.name).then(setExpiration);
     132           11 :     }, [account]);
     133              : 
     134              :     // Only update isLocked field if account changes value
     135           11 :     useEffect(() => {
     136           11 :         setIsLocked(account.isLocked);
     137           11 :     }, [account.isLocked]);
     138              : 
     139            0 :     function changeRealName() {
     140            0 :         setRealNameError("");
     141              : 
     142            0 :         if (editedRealName === null || editedRealName === undefined)
     143            0 :             return;
     144              : 
     145            0 :         if (editedRealName.includes(':')) {
     146            0 :             setRealNameError(_("The full name must not contain colons."));
     147            0 :             return;
     148            0 :         }
     149              : 
     150            0 :         setCommittingRealName(true);
     151              : 
     152            0 :         cockpit.spawn(["/usr/sbin/usermod", user, "--comment", editedRealName],
     153            0 :                       { superuser: "try", err: "message" })
     154            0 :                 .then(() => {
     155            0 :                     setEditedRealName(null);
     156            0 :                     setCommittingRealName(false);
     157            0 :                 })
     158            0 :                 .catch(error => {
     159            0 :                     setEditedRealName(null);
     160            0 :                     setCommittingRealName(false);
     161            0 :                     show_unexpected_error(error);
     162            0 :                 });
     163            0 :     }
     164              : 
     165            1 :     function change_locked(value, dont_retry_if_stuck) {
     166            1 :         setIsLocked(value);
     167            1 :         setDisableLockedEdit(true);
     168              : 
     169            0 :         cockpit.spawn(["/usr/sbin/usermod", user, value ? "--lock" : "--unlock"],
     170            1 :                       { superuser: "require", err: "message" })
     171            1 :                 .then(() => {
     172            1 :                     get_locked(user)
     173            1 :                             .then(locked => {
     174              :                             /* if we care about what the lock state should be and it doesn't match, try to change again
     175              :                                this is a workaround for different ways of handling a locked account
     176              :                                https://github.com/cockpit-project/cockpit/issues/1216
     177              :                                https://bugzilla.redhat.com/show_bug.cgi?id=853153
     178              :                             */
     179            0 :                                 if (locked != value && !dont_retry_if_stuck) {
     180            0 :                                     console.log("Account locked state doesn't match desired value, trying again.");
     181              :                                     // only retry once to avoid uncontrolled recursion
     182            0 :                                     change_locked(value, true);
     183            0 :                                 }
     184            1 :                             });
     185            1 :                 })
     186            0 :                 .catch(error => {
     187            0 :                     show_unexpected_error(error);
     188            0 :                     setIsLocked(!value);
     189            0 :                 })
     190            1 :                 .finally(() => {
     191            1 :                     setDisableLockedEdit(false);
     192            1 :                 });
     193            1 :     }
     194              : 
     195            0 :     function logout_account() {
     196            0 :         cockpit.spawn(["loginctl", "terminate-user", user],
     197            0 :                       { superuser: "try", err: "message" })
     198            0 :                 .then(() => {
     199            0 :                     get_expire(user).then(setExpiration);
     200            0 :                 })
     201            0 :                 .catch(show_unexpected_error);
     202            0 :     }
     203              : 
     204            2 :     if (isLoading) {
     205            2 :         return (
     206            2 :             <EmptyState headingLevel="h1" titleText={_("Loading...")} variant={EmptyStateVariant.sm}>
     207            2 :                 <EmptyStateFooter><Spinner size="xl" /></EmptyStateFooter>
     208            2 :             </EmptyState>
     209              :         );
     210            2 :     }
     211              : 
     212           11 :     if (!expiration)
     213           11 :         return null;
     214              : 
     215            7 :     const self_mod_allowed = (user == current_user || !!superuser.allowed);
     216              : 
     217           11 :     let title_name = account.gecos;
     218           11 :     if (title_name)
     219            2 :         title_name = title_name.split(',')[0];
     220              :     else
     221            4 :         title_name = account.name;
     222              : 
     223           11 :     let last_login;
     224           11 :     if (account.loggedIn)
     225            2 :         last_login = _("Logged in");
     226            6 :     else if (!account.lastLogin)
     227            2 :         last_login = _("Never");
     228              :     else
     229            3 :         last_login = timeformat.dateTime(new Date(account.lastLogin));
     230              : 
     231           11 :     const actions = superuser.allowed && (
     232            9 :         <>
     233            0 :             <Button variant="secondary" onClick={() => logout_account()} id="account-logout"
     234            5 :               isDisabled={!account.loggedIn || account.uid == 0 || user === current_user}>
     235            9 :                 {_("Terminate session")}
     236            9 :             </Button>
     237            9 :             { "\n" }
     238            9 :             <Button isDisabled={account.uid == 0 || user === current_user} variant="danger" id="account-delete"
     239            0 :                   onClick={() => delete_account_dialog(account)}>
     240            9 :                 {_("Delete")}
     241            9 :             </Button>
     242            9 :         </>
     243              :     );
     244              : 
     245           11 :     return (
     246           11 :         <Page id="account" className="pf-m-no-sidebar">
     247           11 :             <PageBreadcrumb hasBodyWrapper={false} stickyOnBreakpoint={{ default: "top" }}>
     248           11 :                 <Breadcrumb>
     249           11 :                     <BreadcrumbItem to="#/">{_("Accounts")}</BreadcrumbItem>
     250           11 :                     <BreadcrumbItem isActive>{title_name}</BreadcrumbItem>
     251           11 :                 </Breadcrumb>
     252           11 :             </PageBreadcrumb>
     253           11 :             <PageSection hasBodyWrapper={false}>
     254           11 :                 <Gallery hasGutter>
     255           11 :                     <Card isPlain className="account-details" id="account-details">
     256           11 :                         <CardHeader actions={{ actions }}>
     257           11 :                             <CardTitle id="account-title" component="h2">{title_name}</CardTitle>
     258           11 :                         </CardHeader>
     259           11 :                         <CardBody>
     260           11 :                             <Form isHorizontal onSubmit={apply_modal_dialog}>
     261           11 :                                 <FormGroup fieldId="account-real-name" hasNoPaddingTop={!superuser.allowed} label={_("Full name")}>
     262           11 :                                     { superuser.allowed
     263            9 :                                         ? <>
     264            9 :                                             <TextInput id="account-real-name"
     265            9 :                                                      isDisabled={comittingRealName || account.uid == 0}
     266            2 :                                                      value={editedRealName !== null ? editedRealName : account.gecos}
     267            0 :                                                      onKeyDown={event => {
     268            0 :                                                          if (event.key == "Enter") {
     269            0 :                                                              event.target.blur();
     270            0 :                                                          }
     271            0 :                                                      }}
     272            0 :                                                      onChange={(_event, value) => setEditedRealName(value)}
     273            2 :                                                      validated={realNameError !== "" ? "error" : "default"}
     274            0 :                                                      onBlur={() => changeRealName()} />
     275            9 :                                             <FormHelper fieldId="account-real-name" helperTextInvalid={realNameError} />
     276            9 :                                         </>
     277            4 :                                         : <output id="account-real-name">{account.gecos}</output>}
     278           11 :                                 </FormGroup>
     279           11 :                                 <FormGroup fieldId="account-user-name" hasNoPaddingTop label={_("User name")}>
     280           11 :                                     <output id="account-user-name">{account.name}</output>
     281           11 :                                 </FormGroup>
     282           11 :                                 <AccountGroupsSelect key={account.name} loggedIn={account.loggedIn} name={account.name} groups={groups} />
     283           11 :                                 <FormGroup fieldId="account-last-login" hasNoPaddingTop label={_("Last login")}>
     284           11 :                                     <output id="account-last-login">{last_login}</output>
     285           11 :                                 </FormGroup>
     286           11 :                                 <FormGroup fieldId="account-locked" label={_("Options")} hasNoPaddingTop>
     287           11 :                                     <Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
     288           11 :                                         <FlexItem spacer={{ default: 'spacerNone' }}>
     289           11 :                                             <Checkbox id="account-locked"
     290           11 :                                                         ouiaSafe={disableLockedEdit}
     291            7 :                                                         isDisabled={!superuser.allowed || disableLockedEdit || user == current_user || isLocked == null}
     292           11 :                                                         isChecked={isLocked}
     293            1 :                                                         onChange={(_event, checked) => change_locked(checked)}
     294           11 :                                                         label={_("Disallow interactive password")} />
     295           11 :                                         </FlexItem>
     296              : 
     297           11 :                                         <Popover bodyContent={_("Other authentication methods are still available even when interactive password authentication is not allowed.")}
     298           11 :                                                     showClose={false}>
     299           11 :                                             <HelpIcon />
     300           11 :                                         </Popover>
     301           11 :                                         <span id="account-expiration-text">
     302           11 :                                             {expiration.account_text}
     303           11 :                                         </span>
     304            0 :                                         <Button onClick={() => account_expiration_dialog(account, expiration.account_date)}
     305           11 :                                                 isDisabled={!superuser.allowed}
     306           11 :                                                 variant="link"
     307           11 :                                                 isInline
     308           11 :                                                 id="account-expiration-button">
     309           11 :                                             {_("edit")}
     310           11 :                                         </Button>
     311           11 :                                     </Flex>
     312           11 :                                 </FormGroup>
     313           11 :                                 { self_mod_allowed &&
     314           11 :                                 <FormGroup fieldId="account-set-password" label={_("Password")}>
     315           11 :                                     <div className="account-column-one">
     316           11 :                                         { self_mod_allowed &&
     317           11 :                                         <Button variant="secondary" id="account-set-password"
     318            1 :                                     onClick={() => set_password_dialog(account, current_user)}>
     319           11 :                                             {_("Set password")}
     320           11 :                                         </Button>
     321              :                                         }
     322           11 :                                         { "\n" }
     323           11 :                                         { superuser.allowed &&
     324            9 :                                         <Button variant="secondary" id="password-reset-button"
     325            1 :                                             onClick={() => reset_password_dialog(account)}>
     326            9 :                                             {_("Force change")}
     327            9 :                                         </Button>
     328              :                                         }
     329           11 :                                     </div>
     330           11 :                                     <Flex flex={{ default: 'inlineFlex' }}>
     331           11 :                                         <span id="password-expiration-text">
     332           11 :                                             {expiration.password_text}
     333           11 :                                         </span>
     334            0 :                                         <Button onClick={() => password_expiration_dialog(account, expiration.password_days)}
     335           11 :                                                 isDisabled={!superuser.allowed}
     336           11 :                                                 variant="link"
     337           11 :                                                 isInline
     338           11 :                                                 id="password-expiration-button">
     339           11 :                                             {_("edit")}
     340           11 :                                         </Button>
     341           11 :                                     </Flex>
     342           11 :                                 </FormGroup>
     343              :                                 }
     344           11 :                                 { account.home && <FormGroup fieldId="account-home-dir" hasNoPaddingTop label={_("Home directory")}>
     345           11 :                                     <output id="account-home-dir">{account.home}</output>
     346           11 :                                 </FormGroup> }
     347           11 :                                 { account.shell && <FormGroup fieldId="account-shell" hasNoPaddingTop label={_("Shell")}>
     348           11 :                                     <Flex flex={{ default: 'inlineFlex' }}>
     349           11 :                                         <output id="account-shell">{account.shell}</output>
     350            1 :                                         <Button onClick={() => account_shell_dialog(account, shells)}
     351           11 :                                                 isDisabled={!superuser.allowed}
     352           11 :                                                 variant="link"
     353           11 :                                                 isInline
     354           11 :                                                 id="change-shell-button">
     355           11 :                                             {_("change")}
     356           11 :                                         </Button>
     357           11 :                                     </Flex>
     358           11 :                                 </FormGroup> }
     359           11 :                             </Form>
     360           11 :                         </CardBody>
     361           11 :                     </Card>
     362           11 :                     <AuthorizedKeys name={account.name} home={account.home} allow_mods={self_mod_allowed} />
     363           11 :                     <AccountLogs name={account.name} />
     364           11 :                 </Gallery>
     365           11 :             </PageSection>
     366           11 :         </Page>
     367              :     );
     368           11 : }
     369              : 
     370           11 : export const AccountGroupsSelect = ({ name, loggedIn, groups }) => {
     371           11 :     const [selected, setSelected] = useState();
     372           11 :     const [primaryGroupName, setPrimaryGroupName] = useState();
     373           11 :     const [loading, setLoading] = useState(true);
     374           11 :     const [modifyingGroup, setModifyingGroup] = useState(false);
     375           11 :     const [history, setHistory] = useState([]);
     376           11 :     const previousValue = useRef(null);
     377              : 
     378           11 :     useEffect(() => {
     379           11 :         const usedGroups = groups.filter(group => group.userlist.includes(name));
     380           11 :         const primaryGroup = groups.find(group => group.userlistPrimary.includes(name));
     381           11 :         const _primaryGroupName = primaryGroup?.name;
     382            5 :         const _selected = usedGroups.map(group => group.name);
     383           11 :         if (primaryGroup)
     384           11 :             _selected.push(_primaryGroupName);
     385              : 
     386           11 :         previousValue.current = _selected;
     387           11 :         setSelected(_selected);
     388           11 :         setLoading(false);
     389           11 :         setPrimaryGroupName(_primaryGroupName);
     390           11 :     }, [groups, setSelected, name, previousValue]);
     391              : 
     392            1 :     const undoGroupChanges = () => {
     393            1 :         const undoItem = history[history.length - 1];
     394            1 :         if (undoItem.type === 'added') {
     395            1 :             removeGroup(undoItem.name, true).then(() => setHistory(history.slice(0, -1)));
     396            1 :         } else if (undoItem.type === 'removed') {
     397            0 :             addGroup(undoItem.name, true).then(() => setHistory(history.slice(0, -1)));
     398            1 :         }
     399            1 :     };
     400              : 
     401            1 :     const removeGroup = (group, isUndo) => {
     402            1 :         if (!isUndo)
     403            1 :             setHistory([...history, { type: 'removed', name: group }]);
     404              : 
     405            1 :         setModifyingGroup(true);
     406            1 :         return cockpit.spawn(["gpasswd", "-d", name, group], { superuser: "require", err: "message" })
     407            1 :                 .then(() => {
     408            1 :                     setModifyingGroup(false);
     409            1 :                 }, show_unexpected_error);
     410            1 :     };
     411              : 
     412            1 :     const addGroup = (group, isUndo) => {
     413            1 :         if (!isUndo)
     414            1 :             setHistory([...history, { type: 'added', name: group }]);
     415              : 
     416            1 :         setModifyingGroup(true);
     417            1 :         return cockpit.spawn(["gpasswd", "-a", name, group], { superuser: "require", err: "message" })
     418            1 :                 .then(() => {
     419            1 :                     setModifyingGroup(false);
     420            1 :                 }, show_unexpected_error);
     421            1 :     };
     422              : 
     423            2 :     const chipGroupComponent = () => {
     424            2 :         return (
     425            2 :             <LabelGroup numLabels={10}>
     426            2 :                 {(selected || []).map((currentLabel, index) => {
     427            2 :                     return (
     428            2 :                         <Label key={currentLabel}
     429            0 :                                color={groups.find(group => group.name === currentLabel).isAdmin ? "yellow" : "blue"}
     430              :                         >
     431            2 :                             {currentLabel}
     432            2 :                         </Label>
     433              :                     );
     434            2 :                 })}
     435            2 :             </LabelGroup>
     436              :         );
     437            2 :     };
     438              : 
     439           11 :     return (
     440           11 :         <FormGroup
     441           11 :             fieldId="account-groups"
     442           11 :             id="account-groups-form-group"
     443           11 :             label={_("Groups")}
     444            2 :             validated={history.length > 0 ? "warning" : "default"}
     445              :         >
     446           11 :             {superuser.allowed
     447            9 :                 ? <MultiTypeaheadSelect
     448            9 :                       isScrollable
     449            9 :                       isDisabled={loading || modifyingGroup}
     450            1 :                       onAdd={val => addGroup(val)}
     451            0 :                       onRemove={val => removeGroup(val)}
     452            9 :                       options={groups.map((option, index) => {
     453            9 :                           return {
     454            9 :                               value: option.name,
     455            9 :                               content: option.name,
     456            9 :                               color: option.isAdmin ? "yellow" : "blue",
     457            9 :                               isDisabled: option.name == primaryGroupName,
     458            9 :                           };
     459            9 :                       })}
     460            9 :                       selected={selected || []}
     461            9 :                       toggleProps={{ id: "account-groups" }} />
     462            4 :                 : chipGroupComponent()}
     463           11 :             {(history.length > 0)
     464            2 :                 ? <HelperText className="pf-v6-c-form__helper-text">
     465            2 :                     <Flex>
     466            2 :                         {loggedIn && <HelperTextItem id="account-groups-helper" variant="warning">{_("The user must log out and log back in for the new configuration to take effect.")}</HelperTextItem>}
     467            2 :                         {history.length > 0 && <Button variant="link" id="group-undo-btn" isInline icon={<UndoIcon />} onClick={undoGroupChanges}>{_("Undo")}</Button>}
     468            2 :                     </Flex>
     469            2 :                 </HelperText>
     470           11 :                 : ''
     471              :             }
     472           11 :         </FormGroup>
     473              :     );
     474           11 : };
        

Generated by: LCOV version 2.0-1