Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions services/console/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"delete_step": "Delete Step",
"delete_user": "Delete User",
"delete_users": "Delete Users",
"create_user": "Create User",
"delete_user_confirmation": "Are you sure you want to delete this user? All existing data will be removed.\n\nNote: If new data is sent for this user, they will be re-created with whatever data is sent.",
"delete_users_instructions": "Please select a CSV containing only an `external_id` column of users to be purged from the system.",
"delivery": "Delivery",
Expand All @@ -122,6 +123,7 @@
"edit_template_details": "Edit Template Details",
"editor_type": "Editor Type",
"email": "Email",
"full_name": "Full Name",
"ended_at": "Ended At",
"endpoint": "Endpoint",
"engagement": "Engagement",
Expand Down Expand Up @@ -156,6 +158,7 @@
"experiment_edit_desc": "Connect this step to others and configure ratios to control what proportion of users will be sent down each path.",
"experiment_ratio": "Ratio",
"experiment_ratio_desc": "{{percentage}}% of users will go down this path.",
"anonymous_id": "Anonymous ID",
"external_id": "External ID",
"failed": "Failed",
"file": "File",
Expand Down
26 changes: 26 additions & 0 deletions services/console/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export type ProjectApiKeyParams = Pick<ProjectApiKey, 'name' | 'description' | '

export interface User {
id: UUID
anonymous_id?: string
external_id: string
full_name?: string
email?: string
Expand Down Expand Up @@ -659,3 +660,28 @@ export interface LocaleOption {
export interface Locale extends LocaleOption {
id: UUID
}

// eslint-disable-next-line @typescript-eslint/no-namespace
export declare namespace Intl {
type Key = 'calendar' | 'collation' | 'currency' | 'numberingSystem' | 'timeZone' | 'unit'
function supportedValuesOf(input: Key): string[]

interface DateTimeFormat {
// eslint-disable-next-line @typescript-eslint/method-signature-style
format(date?: Date | number): string
// eslint-disable-next-line @typescript-eslint/method-signature-style
resolvedOptions(): ResolvedDateTimeFormatOptions
}

interface ResolvedDateTimeFormatOptions {
locale: string
timeZone: string
timeZoneName?: string
}

// eslint-disable-next-line no-var
var DateTimeFormat: {
new(locales?: string | string[]): DateTimeFormat
(locales?: string | string[]): DateTimeFormat
}
}
3 changes: 2 additions & 1 deletion services/console/src/ui/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ export const highlightSearch = (
? text.replaceAll(search, `<strong class="${matchClassName}">$&</strong>`)
: (text ?? '')

export const flattenUser = ({ email, phone, id, external_id, timezone, locale, created_at, devices, data }: User) => orderKeys({
export const flattenUser = ({ email, phone, id, anonymous_id, external_id, timezone, locale, created_at, devices, data }: User) => orderKeys({
...data,
email,
phone,
id,
anonymous_id,
external_id,
created_at,
locale,
Expand Down
27 changes: 1 addition & 26 deletions services/console/src/views/project/ProjectForm.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,13 @@
import api from '../../api'
import TextInput from '../../ui/form/TextInput'
import { Project } from '../../types'
import { Project, Intl } from '../../types'
import FormWrapper from '../../ui/form/FormWrapper'
import { SingleSelect } from '../../ui/form/SingleSelect'
import SwitchField from '../../ui/form/SwitchField'
import Heading from '../../ui/Heading'
import { LocaleTextField } from '../settings/Locales'
import { useTranslation } from 'react-i18next'

// eslint-disable-next-line @typescript-eslint/no-namespace
export declare namespace Intl {
type Key = 'calendar' | 'collation' | 'currency' | 'numberingSystem' | 'timeZone' | 'unit'
function supportedValuesOf(input: Key): string[]

interface DateTimeFormat {
// eslint-disable-next-line @typescript-eslint/method-signature-style
format(date?: Date | number): string
// eslint-disable-next-line @typescript-eslint/method-signature-style
resolvedOptions(): ResolvedDateTimeFormatOptions
}

interface ResolvedDateTimeFormatOptions {
locale: string
timeZone: string
timeZoneName?: string
}

// eslint-disable-next-line no-var
var DateTimeFormat: {
new(locales?: string | string[]): DateTimeFormat
(locales?: string | string[]): DateTimeFormat
}
}

interface ProjectFormProps {
project?: Project
onSave?: (project: Project) => void
Expand Down
1 change: 0 additions & 1 deletion services/console/src/views/users/UserDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import api from '../../api'
import { useTranslation } from 'react-i18next'

export default function UserDetail() {

const { t } = useTranslation()
const navigate = useNavigate()
const [project] = useContext(ProjectContext)
Expand Down
78 changes: 68 additions & 10 deletions services/console/src/views/users/Users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,63 @@ import { useTranslation } from 'react-i18next'
import { Button, Modal } from '../../ui'
import FormWrapper from '../../ui/form/FormWrapper'
import UploadField from '../../ui/form/UploadField'
import { TrashIcon } from '../../ui/icons'
import TextInput from '../../ui/form/TextInput'
import { SingleSelect } from '../../ui/form/SingleSelect'
import { PlusIcon, TrashIcon } from '../../ui/icons'
import { UUID } from 'crypto'
import { NIL } from 'uuid'
import { User, Intl } from '../../types'

export default function UserTabs() {
const { projectId = NIL as UUID } = useParams<{ projectId: UUID }>()
const { t } = useTranslation()
const route = useRoute()
const timeZones = Intl.supportedValuesOf('timeZone')
const locale = navigator.languages[0]?.split('-')[0] ?? 'en'

const state = useSearchTableQueryState(useCallback(async params => await api.users.search(projectId, params), [projectId]))
const [isUploadOpen, setIsUploadOpen] = useState(false)
const [isBulkRemovalOpen, setIsBulkRemovalOpen] = useState(false)
const [isCreateUserOpen, setIsCreateUserOpen] = useState(false)

const defaultUser = {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale,
}

const createUser = async (user: User) => {
const { full_name, ...rest } = user
const newUser: User = {
...rest,
anonymous_id: crypto.randomUUID() as UUID,
...(full_name
? { data: { full_name } }
: { data: user.data }),
}

await api.users.create(projectId, newUser)
await state.reload()

setIsCreateUserOpen(false)
}

const removeUsers = async (file: FileList) => {
const bulkRemoveUsers = async (file: FileList) => {
await api.users.deleteImport(projectId, file[0])
await state.reload()
setIsUploadOpen(false)
setIsBulkRemovalOpen(false)
}

return <PageContent
title={t('users')}
actions={
<Button icon={<TrashIcon />}
onClick={() => setIsUploadOpen(true)}
variant="destructive">{t('delete_users')}</Button>
<>
<Button icon={<TrashIcon />}
onClick={() => setIsBulkRemovalOpen(true)}
variant="destructive">{t('delete_users')}
</Button>
<Button icon={<PlusIcon />}
onClick={() => setIsCreateUserOpen(true)}>{t('create_user')}
</Button>
</>
}>
<SearchTable
{...state}
Expand All @@ -48,11 +82,35 @@ export default function UserTabs() {
/>

<Modal
open={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
open={isCreateUserOpen}
onClose={() => setIsCreateUserOpen(false)}
title={t('create_user')}>
<FormWrapper<User>
defaultValues={defaultUser}
onSubmit={async (form) => await createUser(form)}
submitLabel={t('create')}
>
{form => <>
<TextInput.Field form={form} name="full_name" label={t('full_name')} />
<TextInput.Field form={form} name="email" label={t('email')} />
<TextInput.Field form={form} name="phone" label={t('phone')} />
<SingleSelect.Field
form={form}
options={timeZones}
name="timezone"
label={t('timezone')}
/>
<TextInput.Field form={form} name="locale" label={t('locale')} />
</>}
</FormWrapper>
</Modal>

<Modal
open={isBulkRemovalOpen}
onClose={() => setIsBulkRemovalOpen(false)}
title={t('delete_users')}>
<FormWrapper<{ file: FileList }>
onSubmit={async (form) => await removeUsers(form.file)}
onSubmit={async (form) => await bulkRemoveUsers(form.file)}
submitLabel={t('delete')}
>
{form => <>
Expand Down
67 changes: 66 additions & 1 deletion services/platform/src/users/UserController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { removeUsers } from './UserImport'
import { filterObjectForRulePaths } from '../projects/ProjectRulePathRepository'
import { RulePathVisibility } from '../rules/ProjectRulePath'
import { UUID } from 'node:crypto'
import { ClientIdentifyParams } from 'client/Client'

const router = new Router<
ProjectState & { user?: User }
Expand All @@ -34,6 +35,71 @@ router.get('/', async ctx => {
ctx.body = await pagedUsers(params, ctx.state.project.id)
})

/**
* Identify User
* Used by client libraries to identify and populate a single user
* using a provider external ID
*/
const userParams: JSONSchemaType<ClientIdentifyParams> = {
$id: 'userParams',
type: 'object',
required: [],
properties: {
anonymous_id: {
type: 'string',
nullable: true,
},
external_id: {
type: 'string',
nullable: true,
},
email: {
type: 'string',
nullable: true,
},
phone: {
type: 'string',
nullable: true,
},
timezone: {
type: 'string',
format: 'timezone',
nullable: true,
errorMessage: {
format: 'The timezone value must be in the IANA format.',
},
},
locale: {
type: 'string',
nullable: true,
},
data: {
type: 'object',
nullable: true,
additionalProperties: true,
},
},
anyOf: [
{
required: ['anonymous_id'],
},
{
required: ['external_id'],
},
],
additionalProperties: false,
} as any
Comment thread
jeroenrinzema marked this conversation as resolved.
router.post('/', projectRoleMiddleware('editor'), async ctx => {
const user = validate(userParams, ctx.request.body)
await UserPatchJob.from({
project_id: ctx.state.project.id,
user,
}).queue()

ctx.status = 204
ctx.body = ''
})

const patchUsersRequest: JSONSchemaType<UserParams[]> = {
$id: 'patchUsers',
type: 'array',
Expand Down Expand Up @@ -142,7 +208,6 @@ const deleteUsersRequest: JSONSchemaType<string[]> = {
minItems: 1,
}
router.delete('/', projectRoleMiddleware('editor'), async ctx => {

let userIds = ctx.request.query.user_id || []
if (!Array.isArray(userIds)) userIds = userIds.length ? [userIds] : []

Expand Down
Loading