mirror of
https://github.com/HangarMC/Hangar.git
synced 2024-11-27 06:01:08 +08:00
i18n: Add more translatable fields (#1358)
This commit is contained in:
parent
5addf88f9b
commit
d282755ba9
@ -41,18 +41,18 @@ if (authStore.user) {
|
||||
type NavBarLinks = { link: string; label: string; icon?: any }[];
|
||||
|
||||
const navBarLinks: NavBarLinks = [
|
||||
{ link: "index", label: "Home" },
|
||||
{ link: "authors", label: "Authors" },
|
||||
{ link: "staff", label: "Team" },
|
||||
{ link: "index", label: t("nav.indexTitle") },
|
||||
{ link: "authors", label: t("nav.authorsTitle") },
|
||||
{ link: "staff", label: t("nav.staffTitle") },
|
||||
];
|
||||
|
||||
const navBarMenuLinksHangar: NavBarLinks = [
|
||||
{ link: "index", label: "Home", icon: IconMdiHome },
|
||||
{ link: "guidelines", label: "Resource Guidelines", icon: IconMdiFileDocumentAlert },
|
||||
{ link: "new", label: "Create Project", icon: IconMdiFolderPlusOutline },
|
||||
{ link: "neworganization", label: "Create Organization", icon: IconMdiFolderPlusOutline },
|
||||
{ link: "authors", label: "Authors", icon: IconMdiAccountGroup },
|
||||
{ link: "staff", label: "Team", icon: IconMdiAccountGroup },
|
||||
{ link: "index", label: t("general.home"), icon: IconMdiHome },
|
||||
{ link: "guidelines", label: t("guidelines.title"), icon: IconMdiFileDocumentAlert },
|
||||
{ link: "new", label: t("nav.links.createProject"), icon: IconMdiFolderPlusOutline },
|
||||
{ link: "neworganization", label: t("nav.links.createOrganization"), icon: IconMdiFolderPlusOutline },
|
||||
{ link: "authors", label: t("nav.authorsTitle"), icon: IconMdiAccountGroup },
|
||||
{ link: "staff", label: t("nav.staffTitle"), icon: IconMdiAccountGroup },
|
||||
];
|
||||
if (!authStore.user) {
|
||||
navBarMenuLinksHangar.splice(2, 2);
|
||||
|
@ -25,9 +25,9 @@ defineProps<{
|
||||
<div class="overflow-clip overflow-hidden min-w-0">
|
||||
<div class="inline-flex items-center gap-x-1">
|
||||
<h2>
|
||||
<span class="text-xl font-bold">{{ project.name }}</span>
|
||||
<span class="text-xl font-bold">{{ project.name }} </span>
|
||||
<span class="text-sm"> {{ i18n.t("general.by") }} </span>
|
||||
<span class="text-sm">
|
||||
by
|
||||
<object type="html/sucks">
|
||||
<Link v-slot="{ classes }" custom>
|
||||
<RouterLink :to="'/' + project.namespace.owner" :class="classes"> {{ project.namespace.owner }} </RouterLink>
|
||||
|
@ -6,6 +6,7 @@
|
||||
"icon": "US"
|
||||
},
|
||||
"general": {
|
||||
"by": "by",
|
||||
"close": "Close",
|
||||
"submit": "Submit",
|
||||
"save": "Save",
|
||||
@ -31,6 +32,8 @@
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"tomorrow": "Tomorrow",
|
||||
"used": "Used",
|
||||
"reveal": "Reveal",
|
||||
"error": {
|
||||
"invalidUrl": "Invalid URL format",
|
||||
"nameEmpty": "Name cannot be empty",
|
||||
@ -83,6 +86,9 @@
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"indexTitle": "Home",
|
||||
"authorsTitle": "Authors",
|
||||
"staffTitle": "Staff",
|
||||
"login": "Login",
|
||||
"signup": "Signup",
|
||||
"user": {
|
||||
@ -133,6 +139,10 @@
|
||||
"javadocs": "JavaDocs",
|
||||
"downloads": "Downloads",
|
||||
"community": "Community"
|
||||
},
|
||||
"links": {
|
||||
"createProject": "Create Project",
|
||||
"createOrganization": "Create Organization"
|
||||
}
|
||||
},
|
||||
"guidelines": {
|
||||
@ -933,20 +943,71 @@
|
||||
"auth": {
|
||||
"settings": {
|
||||
"profile": {
|
||||
"header": "Profile"
|
||||
"header": "Profile",
|
||||
"avatar": "Avatar",
|
||||
"tagline": "Tagline",
|
||||
"social": "Social"
|
||||
},
|
||||
"account": {
|
||||
"header": "Account"
|
||||
"header": "Account",
|
||||
"username": "Username",
|
||||
"verifyEmail": "Verify email",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password (Optional)"
|
||||
},
|
||||
"security": {
|
||||
"header": "Security"
|
||||
"header": "Security",
|
||||
"authApp": "Authenticator App",
|
||||
"devices": "Devices",
|
||||
"button": {
|
||||
"setupAuthApp": "Setup 2FA via authenticator app",
|
||||
"setupSecurityKey": "Setup 2FA via security key",
|
||||
"linkGithub": "Link a GitHub account",
|
||||
"linkOther": "Link {0} account",
|
||||
"unlinkAccount": "Unlink {0} account {1}"
|
||||
},
|
||||
"authAppSetup": {
|
||||
"scan": "Scan the QR code on the right using your favorite authenticator app",
|
||||
"cantScan": "Can't scan? Enter the secret listed below the image!",
|
||||
"enterTotp": "Enter a TOTP code generated by your authenticator app in the box below.",
|
||||
"verifyTotp": "Verify TOTP code and activate"
|
||||
},
|
||||
"securityKeys": {
|
||||
"name": "Security Keys",
|
||||
"keyName": "Name",
|
||||
"unregister": "Unregister",
|
||||
"rename": "Rename"
|
||||
},
|
||||
"backupCodes": {
|
||||
"name": "Backup Codes",
|
||||
"generateNew": "Generate new codes",
|
||||
"modal": {
|
||||
"title": "Confirm backup codes",
|
||||
"needConfigure": "You need to configure backup codes before you can activate 2fa. Please save these codes securely!",
|
||||
"confirm": "Confirm that you saved the backup codes by entering one of them below",
|
||||
"backupCode": "Backup Code"
|
||||
}
|
||||
},
|
||||
"unlinkOAuth": {
|
||||
"cantUnlink": "You can't unlink your last oauth credential if you don't have a password set",
|
||||
"modal": {
|
||||
"title": "Successfully unlinked!",
|
||||
"message": "Successfully unlinked your {0} account!",
|
||||
"unlinkUrl": "Click here to remove the Hangar app from your {0} account"
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"header": "API keys"
|
||||
},
|
||||
"misc": {
|
||||
"header": "Other",
|
||||
"accentColor": "Accent Color"
|
||||
"accentColor": "Accent Color",
|
||||
"language": "Language",
|
||||
"alert": {
|
||||
"colorAlert": "The accent colors are mostly untested and full of contrast issues, proceed with caution!",
|
||||
"languageAlert": "Translations are experimental!"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -44,16 +44,16 @@ async function saveAccount() {
|
||||
<div v-if="auth.user">
|
||||
<PageTitle>{{ t("auth.settings.account.header") }}</PageTitle>
|
||||
<form class="flex flex-col gap-2">
|
||||
<InputText v-model="accountForm.username" label="Username" :rules="[required()]" />
|
||||
<InputText v-model="accountForm.username" :label="t('auth.settings.account.username')" :rules="[required()]" />
|
||||
<span class="text-sm opacity-85 -mt-1.5">Note that you can only change your username once every 30 days.</span>
|
||||
<InputText v-model="accountForm.email" label="Email" autofill="username" autocomplete="username" :rules="[required(), email()]" />
|
||||
<Button v-if="!settings?.emailConfirmed" class="w-max" size="small" :disabled="loading" @click.prevent="$emit('openEmailConfirmModal')">
|
||||
Verify email
|
||||
{{ t("auth.settings.account.verifyEmail") }}
|
||||
</Button>
|
||||
<template v-if="settings?.hasPassword">
|
||||
<InputPassword
|
||||
v-model="accountForm.currentPassword"
|
||||
label="Current password"
|
||||
:label="t('auth.settings.account.currentPassword')"
|
||||
name="current-password"
|
||||
autofill="current-password"
|
||||
autocomplete="current-password"
|
||||
@ -61,14 +61,14 @@ async function saveAccount() {
|
||||
/>
|
||||
<InputPassword
|
||||
v-model="accountForm.newPassword"
|
||||
label="New password (optional)"
|
||||
:label="t('auth.settings.account.newPassword')"
|
||||
name="new-password"
|
||||
autofill="new-password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="error" class="text-red">{{ error }}</div>
|
||||
<Button type="submit" class="w-max" :disabled="loading" @click.prevent="saveAccount">Save</Button>
|
||||
<Button type="submit" class="w-max" :disabled="loading" @click.prevent="saveAccount">{{ t("general.save") }}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -38,10 +38,10 @@ watch(locale, async (newLocale) => {
|
||||
<template>
|
||||
<div>
|
||||
<PageTitle>{{ i18n.t("auth.settings.misc.header") }}</PageTitle>
|
||||
<Alert type="warning" class="mb-4">The accent colors are mostly untested and full of contrast issues, proceed with caution!</Alert>
|
||||
<Alert type="warning" class="mb-4">{{ i18n.t("auth.settings.misc.alert.colorAlert") }}</Alert>
|
||||
<InputSelect v-model="accentColor" :values="accentColors" :label="i18n.t('auth.settings.misc.accentColor')" />
|
||||
|
||||
<Alert type="warning" class="my-4">Translations are experimental!</Alert>
|
||||
<Alert type="warning" class="my-4">{{ i18n.t("auth.settings.misc.alert.languageAlert") }}</Alert>
|
||||
<InputSelect v-model="locale" :values="languages" :label="i18n.t('auth.settings.misc.language')" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -51,7 +51,7 @@ async function saveProfile() {
|
||||
<div v-if="auth.user">
|
||||
<PageTitle>{{ t("auth.settings.profile.header") }}</PageTitle>
|
||||
|
||||
<h3 class="text-lg font-bold mb-2">Avatar</h3>
|
||||
<h3 class="text-lg font-bold mb-2">{{ t("auth.settings.profile.avatar") }}</h3>
|
||||
<div class="relative">
|
||||
<UserAvatar :username="auth.user.name" :avatar-url="auth.user.avatarUrl" />
|
||||
<AvatarChangeModal :avatar="auth.user.avatarUrl" :action="`users/${auth.user.name}/settings/avatar`">
|
||||
@ -61,15 +61,15 @@ async function saveProfile() {
|
||||
</AvatarChangeModal>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">Tagline</h3>
|
||||
<InputText v-model="profileForm.tagline" label="Tagline" counter :maxlength="useBackendData.validations.userTagline.max" />
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">{{ t("auth.settings.profile.tagline") }}</h3>
|
||||
<InputText v-model="profileForm.tagline" :label="t('auth.settings.profile.avatar')" counter :maxlength="useBackendData.validations.userTagline.max" />
|
||||
|
||||
<h3 class="text-lg font-bold mt-4">Social</h3>
|
||||
<h3 class="text-lg font-bold mt-4">{{ t("auth.settings.profile.social") }}</h3>
|
||||
<div v-for="(link, idx) in profileForm.socials" :key="link[0]" class="flex items-center mt-2">
|
||||
<span class="w-25">{{ linkTypes.find((e) => e.value === link[0])?.text }}</span>
|
||||
<div class="w-75">
|
||||
<InputText v-if="link[0] === 'website'" v-model="link[1]" label="URL" :rules="[required(), validUrl()]" />
|
||||
<InputText v-else v-model="link[1]" label="Username" :rules="[required()]" />
|
||||
<InputText v-else v-model="link[1]" :label="t('auth.settings.account.username')" :rules="[required()]" />
|
||||
</div>
|
||||
<IconMdiBin class="ml-2 w-6 h-6 cursor-pointer hover:color-red" @click="removeLink(idx)" />
|
||||
</div>
|
||||
@ -78,10 +78,10 @@ async function saveProfile() {
|
||||
<Button button-type="secondary" @click.prevent="addLink">Add link</Button>
|
||||
</div>
|
||||
<div class="w-75">
|
||||
<InputSelect v-model="linkType" :values="linkTypes" label="Type" />
|
||||
<InputSelect v-model="linkType" :values="linkTypes" :label="t('project.settings.links.typeField')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-max mt-2" :disabled="loading" @click.prevent="saveProfile">Save</Button>
|
||||
<Button type="submit" class="w-max mt-2" :disabled="loading" @click.prevent="saveProfile">{{ t("general.save") }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -266,17 +266,17 @@ function closeUnlinkModal() {
|
||||
<template>
|
||||
<div v-if="auth.user">
|
||||
<PageTitle>{{ t("auth.settings.security.header") }}</PageTitle>
|
||||
<h3 class="text-lg font-bold mb-2">Authenticator App</h3>
|
||||
<h3 class="text-lg font-bold mb-2">{{ t("auth.settings.security.authApp") }}</h3>
|
||||
<Button v-if="settings?.hasTotp" :disabled="loading" @click="unlinkTotp">Unlink totp</Button>
|
||||
<Button v-else-if="!totpData" :disabled="loading" @click="setupTotp">Setup 2FA via authenticator app</Button>
|
||||
<Button v-else-if="!totpData" :disabled="loading" @click="setupTotp"> {{ t("auth.settings.security.button.setupAuthApp") }} </Button>
|
||||
<div v-else class="flex lt-sm:flex-col gap-8">
|
||||
<div class="flex flex-col gap-2 basis-1/2">
|
||||
<p>Scan the QR code on the right using your favorite authenticator app</p>
|
||||
<p>Can't scan? Enter the secret listed below the image!</p>
|
||||
<p>{{ t("auth.settings.security.authAppSetup.scan") }}</p>
|
||||
<p>{{ t("auth.settings.security.authAppSetup.cantScan") }}</p>
|
||||
<div class="mt-auto flex flex-col gap-2">
|
||||
<p>Enter a TOTP code generated by your authenticator app in the box below.</p>
|
||||
<p>{{ t("auth.settings.security.authAppSetup.enterTotp") }}</p>
|
||||
<InputText v-model="totpCode" label="TOTP Code" inputmode="numeric" :rules="[requiredIf()(() => totpData != undefined)]" />
|
||||
<Button :disabled="loading || v.$invalid" @click="addTotp">Verify TOTP code and activate</Button>
|
||||
<Button :disabled="loading || v.$invalid" @click="addTotp"> {{ t("auth.settings.security.authAppSetup.verifyTotp") }} </Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="basis-1/2">
|
||||
@ -285,12 +285,16 @@ function closeUnlinkModal() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">Security Keys</h3>
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">{{ t("auth.settings.security.securityKeys.name") }}</h3>
|
||||
<ul v-if="settings?.authenticators">
|
||||
<li v-for="authenticator in settings.authenticators" :key="authenticator.id" class="my-1">
|
||||
{{ authenticator.displayName }} <small class="mr-2">(added at <PrettyTime :time="authenticator.addedAt" long />)</small>
|
||||
<Button size="small" :disabled="loading" @click.prevent="unregisterAuthenticator(authenticator)">Unregister</Button>
|
||||
<Button class="ml-2" size="small" :disabled="loading" @click.prevent="renameAuthenticatorModal(authenticator)">Rename</Button>
|
||||
<Button size="small" :disabled="loading" @click.prevent="unregisterAuthenticator(authenticator)">
|
||||
{{ t("auth.settings.security.securityKeys.unregister") }}
|
||||
</Button>
|
||||
<Button class="ml-2" size="small" :disabled="loading" @click.prevent="renameAuthenticatorModal(authenticator)">
|
||||
{{ t("auth.settings.security.securityKeys.rename") }}
|
||||
</Button>
|
||||
</li>
|
||||
<Modal
|
||||
ref="authenticatorRenameModal"
|
||||
@ -300,25 +304,35 @@ function closeUnlinkModal() {
|
||||
v.$reset();
|
||||
"
|
||||
>
|
||||
<InputText v-model="newAuthenticatorName" label="Name" :rules="[requiredIf()(() => authenticatorRenameModal?.isOpen)]" />
|
||||
<Button class="mt-2" size="small" :disabled="loading" @click.prevent="renameAuthenticator">Rename</Button>
|
||||
<InputText
|
||||
v-model="newAuthenticatorName"
|
||||
:label="t('auth.settings.security.securityKeys.keyName')"
|
||||
:rules="[requiredIf()(() => authenticatorRenameModal?.isOpen)]"
|
||||
/>
|
||||
<Button class="mt-2" size="small" :disabled="loading" @click.prevent="renameAuthenticator">
|
||||
{{ t("auth.settings.security.securityKeys.rename") }}
|
||||
</Button>
|
||||
</Modal>
|
||||
</ul>
|
||||
<div class="my-2">
|
||||
<InputText v-model="authenticatorName" label="Name" :rules="[requiredIf()(() => totpData == undefined && !authenticatorRenameModal?.isOpen)]" />
|
||||
<InputText
|
||||
v-model="authenticatorName"
|
||||
:label="t('auth.settings.security.securityKeys.keyName')"
|
||||
:rules="[requiredIf()(() => totpData == undefined && !authenticatorRenameModal?.isOpen)]"
|
||||
/>
|
||||
</div>
|
||||
<Button :disabled="loading" @click="addAuthenticator">Setup 2FA via security key</Button>
|
||||
<Button :disabled="loading" @click="addAuthenticator"> {{ t("auth.settings.security.button.setupSecurityKey") }} </Button>
|
||||
|
||||
<template v-if="settings?.hasBackupCodes">
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">Backup Codes</h3>
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">{{ t("auth.settings.security.backupCodes.name") }}</h3>
|
||||
<div v-if="showCodes" class="flex flex-wrap mt-2 mb-2">
|
||||
<div v-for="code in codes" :key="code.code" class="basis-3/12">
|
||||
<code>{{ code["used_at"] ? "Used" : code.code }}</code>
|
||||
<code>{{ code["used_at"] ? t("general.used") : code.code }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button v-if="!showCodes" :disabled="loading" @click="revealCodes">Reveal</Button>
|
||||
<Button :disabled="loading" @click="generateNewCodes">Generate new codes</Button>
|
||||
<Button v-if="!showCodes" :disabled="loading" @click="revealCodes"> {{ t("general.reveal") }} </Button>
|
||||
<Button :disabled="loading" @click="generateNewCodes"> {{ t("auth.settings.security.backupCodes.generateNew") }} </Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -327,9 +341,9 @@ function closeUnlinkModal() {
|
||||
<Button v-for="provider in backendData.security.oauthProviders" :key="provider" :disabled="loading" @click="setupOAuth(provider)">
|
||||
<template v-if="provider === 'github'">
|
||||
<IconMdiGithub class="mr-1" />
|
||||
Link a GitHub account
|
||||
{{ t("auth.settings.security.button.linkGithub") }}
|
||||
</template>
|
||||
<template v-else> Link {{ provider }} account</template>
|
||||
<template v-else> {{ t("auth.settings.security.button.linkOther", [provider]) }} </template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
@ -339,37 +353,42 @@ function closeUnlinkModal() {
|
||||
:disabled="!settings?.hasPassword && settings?.oauthConnections.length === 1"
|
||||
:title="
|
||||
!settings?.hasPassword && settings?.oauthConnections.length === 1
|
||||
? 'You can\'t unlink your last oauth credential if you don\'t have a password set'
|
||||
? // ? t('auth.settings.security.unlinkOAuth.cantUnlink') Doesn't work
|
||||
'You can\'t unlink your last oauth credential if you don\'t have a password set'
|
||||
: undefined
|
||||
"
|
||||
@click="unlinkOAuth(credential.provider, credential.id)"
|
||||
>
|
||||
<template v-if="credential.provider === 'github'">
|
||||
<IconMdiGithub class="mr-1" />
|
||||
Unlink GitHub account {{ credential.name }}
|
||||
{{ t("auth.settings.security.button.unlinkAccount", ["GitHub", credential.name]) }}
|
||||
</template>
|
||||
<template v-else> Unlink {{ credential.provider }} account {{ credential.name }}</template>
|
||||
<template v-else> {{ t("auth.settings.security.button.unlinkAccount", [credential.provider, credential.name]) }} </template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal ref="oauthModal" title="Successfully unlinked!" @close="closeUnlinkModal">
|
||||
<p>Successfully unlinked your {{ currentlyUnlinkingProvider }} account!</p>
|
||||
<Link :href="unlinkUrl" target="_blank">Click here to remove the Hangar app from your {{ currentlyUnlinkingProvider }} account</Link>
|
||||
<Modal ref="oauthModal" :title="t('auth.settings.security.unlinkOAuth.modal.title')" @close="closeUnlinkModal">
|
||||
<p>{{ t("auth.settings.security.unlinkOAuth.modal.message", [currentlyUnlinkingProvider]) }}</p>
|
||||
<Link :href="unlinkUrl" target="_blank"> {{ t("auth.settings.security.unlinkOAuth.modal.unlinkUrl", [currentlyUnlinkingProvider]) }} </Link>
|
||||
</Modal>
|
||||
|
||||
<Modal ref="backupCodeModal" title="Confirm backup codes" @close="backupCodeModal.isOpen = false">
|
||||
You need to configure backup codes before you can activate 2fa. Please save these codes securely!
|
||||
<Modal ref="backupCodeModal" :title="t('auth.settings.security.backupCodes.modal.title')" @close="backupCodeModal.isOpen = false">
|
||||
{{ t("auth.settings.security.backupCodes.modal.needConfigure") }}
|
||||
<div class="flex flex-wrap mt-2 mb-2">
|
||||
<div v-for="code in codes" :key="code.code" class="basis-3/12">
|
||||
<code>{{ code.code }}</code>
|
||||
</div>
|
||||
</div>
|
||||
Confirm that you saved the backup codes by entering one of them below
|
||||
<InputText v-model="backupCodeConfirm" label="Backup Code" :rules="[requiredIf()(backupCodeModal?.isOpen)]" />
|
||||
<Button class="mt-2" :disabled="v.$invalid" @click="confirmAndRepeat">Confirm</Button>
|
||||
{{ t("auth.settings.security.backupCodes.modal.confirm") }}
|
||||
<InputText
|
||||
v-model="backupCodeConfirm"
|
||||
:label="t('auth.settings.security.backupCodes.modal.backupCode')"
|
||||
:rules="[requiredIf()(backupCodeModal?.isOpen)]"
|
||||
/>
|
||||
<Button class="mt-2" :disabled="v.$invalid" @click="confirmAndRepeat">{{ t("general.confirm") }}</Button>
|
||||
</Modal>
|
||||
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">Devices</h3>
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">{{ t("auth.settings.security.devices") }}</h3>
|
||||
<ComingSoon>
|
||||
last login<br />
|
||||
on revoke iphone<br />
|
||||
|
Loading…
Reference in New Issue
Block a user