mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add impending deletion filter to workspaces page (#7860)
* add workspace deletion dialog * add deleting_by query param * added test * filtering on workspaces to be deleted * cleaned up form * added story * added banner filter * PR feedback * fix lint and stories * PR feedback * added enterprise test * added unit tests in search_test.go * remove unused fn * unstaged changes
This commit is contained in:
@@ -2,7 +2,9 @@ import { Workspace } from "api/typesGenerated"
|
||||
import { displayImpendingDeletion } from "./utils"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import { Alert } from "components/Alert/Alert"
|
||||
import { formatDistanceToNow, differenceInDays } from "date-fns"
|
||||
import { formatDistanceToNow, differenceInDays, add, format } from "date-fns"
|
||||
import Link from "@mui/material/Link"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
|
||||
export enum Count {
|
||||
Singular,
|
||||
@@ -46,17 +48,34 @@ export const ImpendingDeletionBanner = ({
|
||||
new Date(),
|
||||
)
|
||||
|
||||
const plusFourteen = add(new Date(), { days: 14 })
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity={daysUntilDelete <= 7 ? "warning" : "info"}
|
||||
onDismiss={onDismiss}
|
||||
dismissible
|
||||
>
|
||||
{count === Count.Singular
|
||||
? `This workspace has been unused for ${formatDistanceToNow(
|
||||
Date.parse(workspace.last_used_at),
|
||||
)} and is scheduled for deletion. To keep it, connect via SSH or the web terminal.`
|
||||
: "You have workspaces that will be deleted soon due to inactivity. To keep these workspaces, connect to them via SSH or the web terminal."}
|
||||
{count === Count.Singular ? (
|
||||
`This workspace has been unused for ${formatDistanceToNow(
|
||||
Date.parse(workspace.last_used_at),
|
||||
)} and is scheduled for deletion. To keep it, connect via SSH or the web terminal.`
|
||||
) : (
|
||||
<>
|
||||
<span>There are</span>{" "}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/workspaces?filter=deleting_by:${format(
|
||||
plusFourteen,
|
||||
"y-MM-dd",
|
||||
)}`}
|
||||
>
|
||||
workspaces
|
||||
</Link>{" "}
|
||||
that will be deleted soon due to inactivity. To keep these workspaces,
|
||||
connect to them via SSH or the web terminal.
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
import { InactivityDialog } from "./InactivityDialog"
|
||||
|
||||
const meta: Meta<typeof InactivityDialog> = {
|
||||
title: "InactivityDialog",
|
||||
component: InactivityDialog,
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof InactivityDialog>
|
||||
|
||||
export const OpenDialog: Story = {
|
||||
args: {
|
||||
submitValues: () => null,
|
||||
isInactivityDialogOpen: true,
|
||||
setIsInactivityDialogOpen: () => null,
|
||||
workspacesToBeDeletedToday: 2,
|
||||
},
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
|
||||
|
||||
export const InactivityDialog = ({
|
||||
submitValues,
|
||||
isInactivityDialogOpen,
|
||||
setIsInactivityDialogOpen,
|
||||
workspacesToBeDeletedToday,
|
||||
}: {
|
||||
submitValues: () => void
|
||||
isInactivityDialogOpen: boolean
|
||||
setIsInactivityDialogOpen: (arg0: boolean) => void
|
||||
workspacesToBeDeletedToday: number
|
||||
}) => {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
type="delete"
|
||||
open={isInactivityDialogOpen}
|
||||
onConfirm={() => {
|
||||
submitValues()
|
||||
setIsInactivityDialogOpen(false)
|
||||
}}
|
||||
onClose={() => setIsInactivityDialogOpen(false)}
|
||||
title="Delete inactive workspaces"
|
||||
confirmText="Delete Workspaces"
|
||||
description={`There are ${
|
||||
workspacesToBeDeletedToday ? workspacesToBeDeletedToday : ""
|
||||
} workspaces that already match this filter and will be deleted upon form submission. Are you sure you want to proceed?`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export const TTLHelperText = ({
|
||||
ttl,
|
||||
translationName,
|
||||
}: {
|
||||
ttl?: number
|
||||
translationName: string
|
||||
}) => {
|
||||
const { t } = useTranslation("templateSettingsPage")
|
||||
const count = typeof ttl !== "number" ? 0 : ttl
|
||||
return (
|
||||
// no helper text if ttl is negative - error will show once field is considered touched
|
||||
<Maybe condition={count >= 0}>
|
||||
<span>{t(translationName, { count })}</span>
|
||||
</Maybe>
|
||||
)
|
||||
}
|
||||
+47
-104
@@ -1,12 +1,9 @@
|
||||
import TextField from "@mui/material/TextField"
|
||||
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
|
||||
import { FormikTouched, useFormik } from "formik"
|
||||
import { FC, ChangeEvent } from "react"
|
||||
import { FC, ChangeEvent, useState } from "react"
|
||||
import { getFormHelpers } from "utils/formUtils"
|
||||
import * as Yup from "yup"
|
||||
import i18next from "i18next"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import {
|
||||
FormSection,
|
||||
HorizontalForm,
|
||||
@@ -19,93 +16,16 @@ import Link from "@mui/material/Link"
|
||||
import Checkbox from "@mui/material/Checkbox"
|
||||
import FormControlLabel from "@mui/material/FormControlLabel"
|
||||
import Switch from "@mui/material/Switch"
|
||||
import { InactivityDialog } from "./InactivityDialog"
|
||||
import { useWorkspacesToBeDeleted } from "./useWorkspacesToBeDeleted"
|
||||
import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers"
|
||||
import { TTLHelperText } from "./TTLHelperText"
|
||||
|
||||
const TTLHelperText = ({
|
||||
ttl,
|
||||
translationName,
|
||||
}: {
|
||||
ttl?: number
|
||||
translationName: string
|
||||
}) => {
|
||||
const { t } = useTranslation("templateSettingsPage")
|
||||
const count = typeof ttl !== "number" ? 0 : ttl
|
||||
return (
|
||||
// no helper text if ttl is negative - error will show once field is considered touched
|
||||
<Maybe condition={count >= 0}>
|
||||
<span>{t(translationName, { count })}</span>
|
||||
</Maybe>
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_TTL_DAYS = 7
|
||||
const MS_HOUR_CONVERSION = 3600000
|
||||
const MS_DAY_CONVERSION = 86400000
|
||||
const FAILURE_CLEANUP_DEFAULT = 7
|
||||
const INACTIVITY_CLEANUP_DEFAULT = 180
|
||||
|
||||
export interface TemplateScheduleFormValues extends UpdateTemplateMeta {
|
||||
failure_cleanup_enabled: boolean
|
||||
inactivity_cleanup_enabled: boolean
|
||||
}
|
||||
|
||||
export const getValidationSchema = (): Yup.AnyObjectSchema =>
|
||||
Yup.object({
|
||||
default_ttl_ms: Yup.number()
|
||||
.integer()
|
||||
.min(
|
||||
0,
|
||||
i18next
|
||||
.t("defaultTTLMinError", { ns: "templateSettingsPage" })
|
||||
.toString(),
|
||||
)
|
||||
.max(
|
||||
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
||||
i18next
|
||||
.t("defaultTTLMaxError", { ns: "templateSettingsPage" })
|
||||
.toString(),
|
||||
),
|
||||
max_ttl_ms: Yup.number()
|
||||
.integer()
|
||||
.min(
|
||||
0,
|
||||
i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }).toString(),
|
||||
)
|
||||
.max(
|
||||
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
||||
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }).toString(),
|
||||
),
|
||||
failure_ttl_ms: Yup.number()
|
||||
.min(0, "Failure cleanup days must not be less than 0.")
|
||||
.test(
|
||||
"positive-if-enabled",
|
||||
"Failure cleanup days must be greater than zero when enabled.",
|
||||
function (value) {
|
||||
const parent = this.parent as TemplateScheduleFormValues
|
||||
if (parent.failure_cleanup_enabled) {
|
||||
return Boolean(value)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
),
|
||||
inactivity_ttl_ms: Yup.number()
|
||||
.min(0, "Inactivity cleanup days must not be less than 0.")
|
||||
.test(
|
||||
"positive-if-enabled",
|
||||
"Inactivity cleanup days must be greater than zero when enabled.",
|
||||
function (value) {
|
||||
const parent = this.parent as TemplateScheduleFormValues
|
||||
if (parent.inactivity_cleanup_enabled) {
|
||||
return Boolean(value)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
),
|
||||
allow_user_autostart: Yup.boolean(),
|
||||
allow_user_autostop: Yup.boolean(),
|
||||
})
|
||||
|
||||
export interface TemplateScheduleForm {
|
||||
template: Template
|
||||
onSubmit: (data: UpdateTemplateMeta) => void
|
||||
@@ -154,25 +74,16 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms),
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: (formData) => {
|
||||
// on submit, convert from hours => ms
|
||||
onSubmit({
|
||||
default_ttl_ms: formData.default_ttl_ms
|
||||
? formData.default_ttl_ms * MS_HOUR_CONVERSION
|
||||
: undefined,
|
||||
max_ttl_ms: formData.max_ttl_ms
|
||||
? formData.max_ttl_ms * MS_HOUR_CONVERSION
|
||||
: undefined,
|
||||
failure_ttl_ms: formData.failure_ttl_ms
|
||||
? formData.failure_ttl_ms * MS_DAY_CONVERSION
|
||||
: undefined,
|
||||
inactivity_ttl_ms: formData.inactivity_ttl_ms
|
||||
? formData.inactivity_ttl_ms * MS_DAY_CONVERSION
|
||||
: undefined,
|
||||
|
||||
allow_user_autostart: formData.allow_user_autostart,
|
||||
allow_user_autostop: formData.allow_user_autostop,
|
||||
})
|
||||
onSubmit: () => {
|
||||
if (
|
||||
form.values.inactivity_cleanup_enabled &&
|
||||
workspacesToBeDeletedToday &&
|
||||
workspacesToBeDeletedToday.length > 0
|
||||
) {
|
||||
setIsInactivityDialogOpen(true)
|
||||
} else {
|
||||
submitValues()
|
||||
}
|
||||
},
|
||||
initialTouched,
|
||||
})
|
||||
@@ -183,6 +94,32 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
const { t } = useTranslation("templateSettingsPage")
|
||||
const styles = useStyles()
|
||||
|
||||
const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(form.values)
|
||||
|
||||
const [isInactivityDialogOpen, setIsInactivityDialogOpen] =
|
||||
useState<boolean>(false)
|
||||
|
||||
const submitValues = () => {
|
||||
// on submit, convert from hours => ms
|
||||
onSubmit({
|
||||
default_ttl_ms: form.values.default_ttl_ms
|
||||
? form.values.default_ttl_ms * MS_HOUR_CONVERSION
|
||||
: undefined,
|
||||
max_ttl_ms: form.values.max_ttl_ms
|
||||
? form.values.max_ttl_ms * MS_HOUR_CONVERSION
|
||||
: undefined,
|
||||
failure_ttl_ms: form.values.failure_ttl_ms
|
||||
? form.values.failure_ttl_ms * MS_DAY_CONVERSION
|
||||
: undefined,
|
||||
inactivity_ttl_ms: form.values.inactivity_ttl_ms
|
||||
? form.values.inactivity_ttl_ms * MS_DAY_CONVERSION
|
||||
: undefined,
|
||||
|
||||
allow_user_autostart: form.values.allow_user_autostart,
|
||||
allow_user_autostop: form.values.allow_user_autostop,
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleFailureCleanup = async (e: ChangeEvent) => {
|
||||
form.handleChange(e)
|
||||
if (!form.values.failure_cleanup_enabled) {
|
||||
@@ -395,6 +332,12 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
</FormSection>
|
||||
</>
|
||||
)}
|
||||
<InactivityDialog
|
||||
submitValues={submitValues}
|
||||
isInactivityDialogOpen={isInactivityDialogOpen}
|
||||
setIsInactivityDialogOpen={setIsInactivityDialogOpen}
|
||||
workspacesToBeDeletedToday={workspacesToBeDeletedToday?.length ?? 0}
|
||||
/>
|
||||
<FormFooter
|
||||
onCancel={onCancel}
|
||||
isLoading={isSubmitting}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { UpdateTemplateMeta } from "api/typesGenerated"
|
||||
import * as Yup from "yup"
|
||||
import i18next from "i18next"
|
||||
|
||||
export interface TemplateScheduleFormValues extends UpdateTemplateMeta {
|
||||
failure_cleanup_enabled: boolean
|
||||
inactivity_cleanup_enabled: boolean
|
||||
}
|
||||
|
||||
const MAX_TTL_DAYS = 7
|
||||
|
||||
export const getValidationSchema = (): Yup.AnyObjectSchema =>
|
||||
Yup.object({
|
||||
default_ttl_ms: Yup.number()
|
||||
.integer()
|
||||
.min(
|
||||
0,
|
||||
i18next
|
||||
.t("defaultTTLMinError", { ns: "templateSettingsPage" })
|
||||
.toString(),
|
||||
)
|
||||
.max(
|
||||
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
||||
i18next
|
||||
.t("defaultTTLMaxError", { ns: "templateSettingsPage" })
|
||||
.toString(),
|
||||
),
|
||||
max_ttl_ms: Yup.number()
|
||||
.integer()
|
||||
.min(
|
||||
0,
|
||||
i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }).toString(),
|
||||
)
|
||||
.max(
|
||||
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
||||
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }).toString(),
|
||||
),
|
||||
failure_ttl_ms: Yup.number()
|
||||
.min(0, "Failure cleanup days must not be less than 0.")
|
||||
.test(
|
||||
"positive-if-enabled",
|
||||
"Failure cleanup days must be greater than zero when enabled.",
|
||||
function (value) {
|
||||
const parent = this.parent as TemplateScheduleFormValues
|
||||
if (parent.failure_cleanup_enabled) {
|
||||
return Boolean(value)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
),
|
||||
inactivity_ttl_ms: Yup.number()
|
||||
.min(0, "Inactivity cleanup days must not be less than 0.")
|
||||
.test(
|
||||
"positive-if-enabled",
|
||||
"Inactivity cleanup days must be greater than zero when enabled.",
|
||||
function (value) {
|
||||
const parent = this.parent as TemplateScheduleFormValues
|
||||
if (parent.inactivity_cleanup_enabled) {
|
||||
return Boolean(value)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
),
|
||||
allow_user_autostart: Yup.boolean(),
|
||||
allow_user_autostop: Yup.boolean(),
|
||||
})
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getWorkspaces } from "api/api"
|
||||
import { compareAsc, add, endOfToday } from "date-fns"
|
||||
import { WorkspaceStatus, Workspace } from "api/typesGenerated"
|
||||
import { TemplateScheduleFormValues } from "./formHelpers"
|
||||
|
||||
const inactiveStatuses: WorkspaceStatus[] = [
|
||||
"stopped",
|
||||
"canceled",
|
||||
"failed",
|
||||
"deleted",
|
||||
]
|
||||
|
||||
export const useWorkspacesToBeDeleted = (
|
||||
formValues: TemplateScheduleFormValues,
|
||||
) => {
|
||||
const { data: workspacesData } = useQuery({
|
||||
queryKey: ["workspaces"],
|
||||
queryFn: () => getWorkspaces({}),
|
||||
enabled: formValues.inactivity_cleanup_enabled,
|
||||
})
|
||||
return workspacesData?.workspaces?.filter((workspace: Workspace) => {
|
||||
const isInactive = inactiveStatuses.includes(workspace.latest_build.status)
|
||||
|
||||
const proposedDeletion = add(new Date(workspace.last_used_at), {
|
||||
days: formValues.inactivity_ttl_ms,
|
||||
})
|
||||
|
||||
if (isInactive && compareAsc(proposedDeletion, endOfToday()) < 1) {
|
||||
return workspace
|
||||
}
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ import {
|
||||
renderWithTemplateSettingsLayout,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "testHelpers/renderHelpers"
|
||||
import { getValidationSchema } from "./TemplateScheduleForm"
|
||||
import { getValidationSchema } from "./TemplateScheduleForm/formHelpers"
|
||||
import TemplateSchedulePage from "./TemplateSchedulePage"
|
||||
import i18next from "i18next"
|
||||
|
||||
|
||||
+37
-22
@@ -1,31 +1,46 @@
|
||||
import { action } from "@storybook/addon-actions"
|
||||
import { Story } from "@storybook/react"
|
||||
import { Meta, StoryObj } from "@storybook/react"
|
||||
import { MockTemplate } from "testHelpers/entities"
|
||||
import {
|
||||
TemplateSchedulePageView,
|
||||
TemplateSchedulePageViewProps,
|
||||
} from "./TemplateSchedulePageView"
|
||||
import { TemplateSchedulePageView } from "./TemplateSchedulePageView"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
|
||||
export default {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "offlineFirst",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const meta: Meta<typeof TemplateSchedulePageView> = {
|
||||
title: "pages/TemplateSchedulePageView",
|
||||
component: TemplateSchedulePageView,
|
||||
args: {
|
||||
allowAdvancedScheduling: true,
|
||||
allowWorkspaceActions: true,
|
||||
template: MockTemplate,
|
||||
onSubmit: action("onSubmit"),
|
||||
onCancel: action("cancel"),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
],
|
||||
}
|
||||
export default meta
|
||||
type Story = StoryObj<typeof TemplateSchedulePageView>
|
||||
|
||||
const defaultArgs = {
|
||||
allowAdvancedScheduling: true,
|
||||
allowWorkspaceActions: true,
|
||||
template: MockTemplate,
|
||||
onSubmit: action("onSubmit"),
|
||||
onCancel: action("cancel"),
|
||||
}
|
||||
|
||||
const Template: Story<TemplateSchedulePageViewProps> = (args) => (
|
||||
<TemplateSchedulePageView {...args} />
|
||||
)
|
||||
export const Example: Story = {
|
||||
args: { ...defaultArgs },
|
||||
}
|
||||
|
||||
export const Example = Template.bind({})
|
||||
Example.args = {}
|
||||
|
||||
export const CantSetMaxTTL = Template.bind({})
|
||||
CantSetMaxTTL.args = {
|
||||
allowAdvancedScheduling: false,
|
||||
export const CantSetMaxTTL: Story = {
|
||||
args: { ...defaultArgs, allowAdvancedScheduling: false },
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
|
||||
import { ComponentProps, FC } from "react"
|
||||
import { TemplateScheduleForm } from "./TemplateScheduleForm"
|
||||
import { TemplateScheduleForm } from "./TemplateScheduleForm/TemplateScheduleForm"
|
||||
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { screen } from "@testing-library/react"
|
||||
import { rest } from "msw"
|
||||
import * as CreateDayString from "utils/createDayString"
|
||||
import {
|
||||
MockWorkspace,
|
||||
MockWorkspacesResponse,
|
||||
MockEntitlementsWithScheduling,
|
||||
MockWorkspacesResponseWithDeletions,
|
||||
} from "testHelpers/entities"
|
||||
import { MockWorkspace, MockWorkspacesResponse } from "testHelpers/entities"
|
||||
import { history, renderWithAuth } from "testHelpers/renderHelpers"
|
||||
import { server } from "testHelpers/server"
|
||||
import WorkspacesPage from "./WorkspacesPage"
|
||||
import { i18n } from "i18n"
|
||||
import * as API from "api/api"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
|
||||
const { t } = i18n
|
||||
|
||||
@@ -48,24 +41,4 @@ describe("WorkspacesPage", () => {
|
||||
)
|
||||
expect(templateDisplayNames).toHaveLength(MockWorkspacesResponse.count)
|
||||
})
|
||||
|
||||
it("displays banner for impending deletions", async () => {
|
||||
jest
|
||||
.spyOn(API, "getEntitlements")
|
||||
.mockResolvedValue(MockEntitlementsWithScheduling)
|
||||
|
||||
jest
|
||||
.spyOn(API, "getWorkspaces")
|
||||
.mockResolvedValue(MockWorkspacesResponseWithDeletions)
|
||||
|
||||
renderWithAuth(<WorkspacesPage />)
|
||||
|
||||
const banner = await screen.findByText(
|
||||
"You have workspaces that will be deleted soon due to inactivity. To keep these workspaces, connect to them via SSH or the web terminal.",
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId("dismiss-banner-btn"))
|
||||
|
||||
expect(banner).toBeEmptyDOMElement
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user