From 16743723d45331cc3c0665e17427e6a0a7ffeefc Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Wed, 15 Oct 2025 10:14:20 +0200 Subject: [PATCH 1/2] feat: include ability to create user from console, closes #15 --- services/console/public/locales/en.json | 3 + services/console/src/types.ts | 1 + services/console/src/ui/utils.tsx | 3 +- .../console/src/views/users/UserDetail.tsx | 1 - services/console/src/views/users/Users.tsx | 101 ++++++++++++++++-- services/platform/src/users/UserController.ts | 67 +++++++++++- 6 files changed, 163 insertions(+), 13 deletions(-) diff --git a/services/console/public/locales/en.json b/services/console/public/locales/en.json index 321e0221..5d064d9c 100644 --- a/services/console/public/locales/en.json +++ b/services/console/public/locales/en.json @@ -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", @@ -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", @@ -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", diff --git a/services/console/src/types.ts b/services/console/src/types.ts index 691cd65f..294b8102 100644 --- a/services/console/src/types.ts +++ b/services/console/src/types.ts @@ -248,6 +248,7 @@ export type ProjectApiKeyParams = Pick$&`) : (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, diff --git a/services/console/src/views/users/UserDetail.tsx b/services/console/src/views/users/UserDetail.tsx index df7a77f2..80db9e84 100644 --- a/services/console/src/views/users/UserDetail.tsx +++ b/services/console/src/views/users/UserDetail.tsx @@ -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) diff --git a/services/console/src/views/users/Users.tsx b/services/console/src/views/users/Users.tsx index 805b3973..b51f1a70 100644 --- a/services/console/src/views/users/Users.tsx +++ b/services/console/src/views/users/Users.tsx @@ -8,29 +8,86 @@ 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 } from '../../types' + +// 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 + } +} 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) => { + user.anonymous_id = crypto.randomUUID() as UUID - const removeUsers = async (file: FileList) => { + if (user.full_name) { + user.data = { full_name: user.full_name } + user.full_name = undefined + } + + await api.users.create(projectId, user) + await state.reload() + + setIsCreateUserOpen(false) + } + + const bulkRemoveUsers = async (file: FileList) => { await api.users.deleteImport(projectId, file[0]) await state.reload() - setIsUploadOpen(false) + setIsBulkRemovalOpen(false) } return } - onClick={() => setIsUploadOpen(true)} - variant="destructive">{t('delete_users')} + <> + + + }> setIsUploadOpen(false)} + open={isCreateUserOpen} + onClose={() => setIsCreateUserOpen(false)} + title={t('create_user')}> + + defaultValues={defaultUser} + onSubmit={async (form) => await createUser(form)} + submitLabel={t('create')} + > + {form => <> + + + + + + } + + + + setIsBulkRemovalOpen(false)} title={t('delete_users')}> - onSubmit={async (form) => await removeUsers(form.file)} + onSubmit={async (form) => await bulkRemoveUsers(form.file)} submitLabel={t('delete')} > {form => <> diff --git a/services/platform/src/users/UserController.ts b/services/platform/src/users/UserController.ts index d6136c43..e199d9d1 100644 --- a/services/platform/src/users/UserController.ts +++ b/services/platform/src/users/UserController.ts @@ -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 } @@ -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 = { + $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 +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 = { $id: 'patchUsers', type: 'array', @@ -142,7 +208,6 @@ const deleteUsersRequest: JSONSchemaType = { minItems: 1, } router.delete('/', projectRoleMiddleware('editor'), async ctx => { - let userIds = ctx.request.query.user_id || [] if (!Array.isArray(userIds)) userIds = userIds.length ? [userIds] : [] From 202742e4312e0e105f8e8deed896623f3e4f5cb8 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Wed, 15 Oct 2025 11:00:16 +0200 Subject: [PATCH 2/2] chore: addressed copilot comments --- services/console/src/types.ts | 25 +++++++++++ .../console/src/views/project/ProjectForm.tsx | 27 +----------- services/console/src/views/users/Users.tsx | 41 ++++--------------- 3 files changed, 35 insertions(+), 58 deletions(-) diff --git a/services/console/src/types.ts b/services/console/src/types.ts index 294b8102..dc05aee3 100644 --- a/services/console/src/types.ts +++ b/services/console/src/types.ts @@ -660,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 + } +} diff --git a/services/console/src/views/project/ProjectForm.tsx b/services/console/src/views/project/ProjectForm.tsx index 0f4f39bd..77f79af1 100644 --- a/services/console/src/views/project/ProjectForm.tsx +++ b/services/console/src/views/project/ProjectForm.tsx @@ -1,6 +1,6 @@ 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' @@ -8,31 +8,6 @@ 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 diff --git a/services/console/src/views/users/Users.tsx b/services/console/src/views/users/Users.tsx index b51f1a70..61c0dad5 100644 --- a/services/console/src/views/users/Users.tsx +++ b/services/console/src/views/users/Users.tsx @@ -13,32 +13,7 @@ import { SingleSelect } from '../../ui/form/SingleSelect' import { PlusIcon, TrashIcon } from '../../ui/icons' import { UUID } from 'crypto' import { NIL } from 'uuid' -import { User } from '../../types' - -// 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 - } -} +import { User, Intl } from '../../types' export default function UserTabs() { const { projectId = NIL as UUID } = useParams<{ projectId: UUID }>() @@ -57,14 +32,16 @@ export default function UserTabs() { } const createUser = async (user: User) => { - user.anonymous_id = crypto.randomUUID() as UUID - - if (user.full_name) { - user.data = { full_name: user.full_name } - user.full_name = undefined + 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, user) + await api.users.create(projectId, newUser) await state.reload() setIsCreateUserOpen(false)