From e77a390d6dbc3ae8e226c0397aca3c556b5b722e Mon Sep 17 00:00:00 2001 From: Parker Stafford <52351508+Parker-Stafford@users.noreply.github.com> Date: Thu, 22 Aug 2024 13:00:57 -0700 Subject: [PATCH] feat(auth): add create user modal to ui (#4319) * feat(auth): add create user modal to ui * add create user form and modal * fix overflow * feedback * default to member, lowercase roles in table --------- Co-authored-by: Mikyo King --- .../components/dataset/CreateDatasetForm.tsx | 1 + app/src/components/dataset/DatasetForm.tsx | 6 +- .../components/dataset/EditDatasetForm.tsx | 1 + app/src/components/settings/RolePicker.tsx | 45 +++++ app/src/components/settings/UserForm.tsx | 169 ++++++++++++++++++ app/src/constants/index.ts | 4 + app/src/constants/userConstants.ts | 4 + app/src/pages/settings/NewUserDialog.tsx | 62 +++++++ app/src/pages/settings/SettingsPage.tsx | 86 ++++----- app/src/pages/settings/UsersCard.tsx | 89 +++++++++ app/src/pages/settings/UsersTable.tsx | 16 +- .../NewUserDialogMutation.graphql.ts | 117 ++++++++++++ .../__generated__/UsersCardQuery.graphql.ts | 130 ++++++++++++++ .../__generated__/UsersTableQuery.graphql.ts | 137 -------------- .../__generated__/UsersTable_users.graphql.ts | 118 ++++++++++++ src/phoenix/auth.py | 3 + 16 files changed, 800 insertions(+), 188 deletions(-) create mode 100644 app/src/components/settings/RolePicker.tsx create mode 100644 app/src/components/settings/UserForm.tsx create mode 100644 app/src/constants/index.ts create mode 100644 app/src/constants/userConstants.ts create mode 100644 app/src/pages/settings/NewUserDialog.tsx create mode 100644 app/src/pages/settings/UsersCard.tsx create mode 100644 app/src/pages/settings/__generated__/NewUserDialogMutation.graphql.ts create mode 100644 app/src/pages/settings/__generated__/UsersCardQuery.graphql.ts delete mode 100644 app/src/pages/settings/__generated__/UsersTableQuery.graphql.ts create mode 100644 app/src/pages/settings/__generated__/UsersTable_users.graphql.ts diff --git a/app/src/components/dataset/CreateDatasetForm.tsx b/app/src/components/dataset/CreateDatasetForm.tsx index f2952cdab4..519c08c4be 100644 --- a/app/src/components/dataset/CreateDatasetForm.tsx +++ b/app/src/components/dataset/CreateDatasetForm.tsx @@ -58,6 +58,7 @@ export function CreateDatasetForm(props: CreateDatasetFormProps) { isSubmitting={isCommitting} onSubmit={onSubmit} submitButtonText={isCommitting ? "Creating..." : "Create Dataset"} + formMode="create" /> ); } diff --git a/app/src/components/dataset/DatasetForm.tsx b/app/src/components/dataset/DatasetForm.tsx index 1c3b202426..f53d1bcf4e 100644 --- a/app/src/components/dataset/DatasetForm.tsx +++ b/app/src/components/dataset/DatasetForm.tsx @@ -26,6 +26,7 @@ export function DatasetForm({ onSubmit, isSubmitting, submitButtonText, + formMode, }: { datasetName?: string | null; datasetDescription?: string | null; @@ -33,6 +34,7 @@ export function DatasetForm({ onSubmit: (params: DatasetFormParams) => void; isSubmitting: boolean; submitButtonText: string; + formMode: "create" | "edit"; }) { const { control, @@ -125,7 +127,9 @@ export function DatasetForm({ > + + + + + ); +} diff --git a/app/src/constants/index.ts b/app/src/constants/index.ts new file mode 100644 index 0000000000..4c4cabdcaa --- /dev/null +++ b/app/src/constants/index.ts @@ -0,0 +1,4 @@ +export * from "./userConstants"; +export * from "./timeConstants"; +export * from "./numberConstants"; +export * from "./pointCloudConstants"; diff --git a/app/src/constants/userConstants.ts b/app/src/constants/userConstants.ts new file mode 100644 index 0000000000..be65c0482f --- /dev/null +++ b/app/src/constants/userConstants.ts @@ -0,0 +1,4 @@ +export enum UserRole { + ADMIN = "ADMIN", + MEMBER = "MEMBER", +} diff --git a/app/src/pages/settings/NewUserDialog.tsx b/app/src/pages/settings/NewUserDialog.tsx new file mode 100644 index 0000000000..3c52a2915a --- /dev/null +++ b/app/src/pages/settings/NewUserDialog.tsx @@ -0,0 +1,62 @@ +import React, { useCallback } from "react"; +import { graphql, useMutation } from "react-relay"; + +import { Dialog, DialogContainer } from "@arizeai/components"; + +import { + UserForm, + UserFormParams, +} from "@phoenix/components/settings/UserForm"; + +import { NewUserDialogMutation } from "./__generated__/NewUserDialogMutation.graphql"; + +export function NewUserDialog({ + onNewUserCreated, + onNewUserCreationError, + onDismiss, +}: { + onNewUserCreated: (email: string) => void; + onNewUserCreationError: (error: Error) => void; + onDismiss: () => void; +}) { + const [commit, isCommitting] = useMutation(graphql` + mutation NewUserDialogMutation($input: CreateUserInput!) { + createUser(input: $input) { + user { + id + email + } + } + } + `); + + const onSubmit = useCallback( + (data: UserFormParams) => { + commit({ + variables: { + input: data, + }, + onCompleted: (response) => { + onNewUserCreated(response.createUser.user.email); + }, + onError: (error) => { + onNewUserCreationError(error); + }, + }); + }, + [commit, onNewUserCreated, onNewUserCreationError] + ); + + return ( + + + + + + ); +} diff --git a/app/src/pages/settings/SettingsPage.tsx b/app/src/pages/settings/SettingsPage.tsx index 5cb71b65c6..4e10151071 100644 --- a/app/src/pages/settings/SettingsPage.tsx +++ b/app/src/pages/settings/SettingsPage.tsx @@ -1,16 +1,20 @@ -import React, { Suspense } from "react"; +import React from "react"; import { css } from "@emotion/react"; import { Card, Flex, TextField, View } from "@arizeai/components"; -import { CopyToClipboardButton, Loading } from "@phoenix/components"; +import { CopyToClipboardButton } from "@phoenix/components"; import { BASE_URL, VERSION } from "@phoenix/config"; import { useFunctionality } from "@phoenix/contexts/FunctionalityContext"; import { APIKeysCard } from "./APIKeysCard"; -import { UsersTable } from "./UsersTable"; +import { UsersCard } from "./UsersCard"; const settingsPageCSS = css` + overflow-y: auto; +`; + +const settingsPageInnerCSS = css` padding: var(--ac-global-dimension-size-400); max-width: 800px; min-width: 500px; @@ -31,47 +35,43 @@ export function SettingsPage() { const { authenticationEnabled } = useFunctionality(); return (
- - -
- - - - - - - - - - - - -
-
- {authenticationEnabled && } - {authenticationEnabled && ( - - }> - - +
+ + +
+ + + + + + + + + + + + +
- )} -
+ {authenticationEnabled && } + {authenticationEnabled && } + +
); } diff --git a/app/src/pages/settings/UsersCard.tsx b/app/src/pages/settings/UsersCard.tsx new file mode 100644 index 0000000000..9467117688 --- /dev/null +++ b/app/src/pages/settings/UsersCard.tsx @@ -0,0 +1,89 @@ +import React, { ReactNode, Suspense, useState } from "react"; +import { graphql, useLazyLoadQuery } from "react-relay"; + +import { + Button, + Card, + DialogContainer, + Icon, + Icons, +} from "@arizeai/components"; + +import { Loading } from "@phoenix/components"; +import { useNotifyError, useNotifySuccess } from "@phoenix/contexts"; + +import { UsersCardQuery } from "./__generated__/UsersCardQuery.graphql"; +import { NewUserDialog } from "./NewUserDialog"; +import { UsersTable } from "./UsersTable"; + +export function UsersCard() { + const [fetchKey, setFetchKey] = useState(0); + const [dialog, setDialog] = useState(null); + + const notifySuccess = useNotifySuccess(); + const notifyError = useNotifyError(); + + const data = useLazyLoadQuery( + graphql` + query UsersCardQuery { + ...UsersTable_users + } + `, + {}, + { + fetchKey: fetchKey, + fetchPolicy: "store-and-network", + } + ); + + return ( + { + setDialog( + { + setDialog(null); + }} + onNewUserCreated={(email) => { + notifySuccess({ + title: "User added", + message: `User ${email} has been added.`, + }); + setFetchKey((prev) => prev + 1); + setDialog(null); + }} + onNewUserCreationError={(error) => { + notifyError({ + title: "Error adding user", + message: error.message, + }); + }} + /> + ); + }} + variant="default" + size="compact" + icon={} />} + > + Add User + + } + > + }> + + + { + setDialog(null); + }} + > + {dialog} + + + ); +} diff --git a/app/src/pages/settings/UsersTable.tsx b/app/src/pages/settings/UsersTable.tsx index 46429ca519..f95423fe7e 100644 --- a/app/src/pages/settings/UsersTable.tsx +++ b/app/src/pages/settings/UsersTable.tsx @@ -1,8 +1,9 @@ import React, { useMemo } from "react"; -import { graphql, useLazyLoadQuery } from "react-relay"; +import { graphql, useFragment } from "react-relay"; import { flexRender, getCoreRowModel, + getSortedRowModel, useReactTable, } from "@tanstack/react-table"; @@ -12,12 +13,12 @@ import { tableCSS } from "@phoenix/components/table/styles"; import { TableEmpty } from "@phoenix/components/table/TableEmpty"; import { TimestampCell } from "@phoenix/components/table/TimestampCell"; -import { UsersTableQuery } from "./__generated__/UsersTableQuery.graphql"; +import { UsersTable_users$key } from "./__generated__/UsersTable_users.graphql"; -export function UsersTable() { - const data = useLazyLoadQuery( +export function UsersTable({ query }: { query: UsersTable_users$key }) { + const data = useFragment( graphql` - query UsersTableQuery { + fragment UsersTable_users on Query { users { edges { user: node { @@ -32,7 +33,7 @@ export function UsersTable() { } } `, - {} + query ); const tableData = useMemo(() => { @@ -40,7 +41,7 @@ export function UsersTable() { email: user.email, username: user.username, createdAt: user.createdAt, - roleName: user.role.name, + roleName: user.role.name.toLocaleLowerCase(), })); }, [data]); @@ -67,6 +68,7 @@ export function UsersTable() { ], data: tableData, getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), }); const rows = table.getRowModel().rows; const isEmpty = table.getRowModel().rows.length === 0; diff --git a/app/src/pages/settings/__generated__/NewUserDialogMutation.graphql.ts b/app/src/pages/settings/__generated__/NewUserDialogMutation.graphql.ts new file mode 100644 index 0000000000..41d0e756c5 --- /dev/null +++ b/app/src/pages/settings/__generated__/NewUserDialogMutation.graphql.ts @@ -0,0 +1,117 @@ +/** + * @generated SignedSource<<140bf0525a033304413948cea0200e29>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest, Mutation } from 'relay-runtime'; +export type UserRoleInput = "ADMIN" | "MEMBER"; +export type CreateUserInput = { + email: string; + password: string; + role: UserRoleInput; + username?: string | null; +}; +export type NewUserDialogMutation$variables = { + input: CreateUserInput; +}; +export type NewUserDialogMutation$data = { + readonly createUser: { + readonly user: { + readonly email: string; + readonly id: string; + }; + }; +}; +export type NewUserDialogMutation = { + response: NewUserDialogMutation$data; + variables: NewUserDialogMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "UserMutationPayload", + "kind": "LinkedField", + "name": "createUser", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "user", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "email", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "NewUserDialogMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "NewUserDialogMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "a9bf3794a5355d9d0e0a3acebe7f3809", + "id": null, + "metadata": {}, + "name": "NewUserDialogMutation", + "operationKind": "mutation", + "text": "mutation NewUserDialogMutation(\n $input: CreateUserInput!\n) {\n createUser(input: $input) {\n user {\n id\n email\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "97d1aefa41e8831e57b6bb0d1a078a7c"; + +export default node; diff --git a/app/src/pages/settings/__generated__/UsersCardQuery.graphql.ts b/app/src/pages/settings/__generated__/UsersCardQuery.graphql.ts new file mode 100644 index 0000000000..80ea0586f7 --- /dev/null +++ b/app/src/pages/settings/__generated__/UsersCardQuery.graphql.ts @@ -0,0 +1,130 @@ +/** + * @generated SignedSource<<3d4d387e015d37cc173f1296a745d953>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest, Query } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type UsersCardQuery$variables = Record; +export type UsersCardQuery$data = { + readonly " $fragmentSpreads": FragmentRefs<"UsersTable_users">; +}; +export type UsersCardQuery = { + response: UsersCardQuery$data; + variables: UsersCardQuery$variables; +}; + +const node: ConcreteRequest = { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "UsersCardQuery", + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "UsersTable_users" + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "UsersCardQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "UserConnection", + "kind": "LinkedField", + "name": "users", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "UserEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": "user", + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "email", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "username", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "UserRole", + "kind": "LinkedField", + "name": "role", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "475e4a6a89583e17246b7f9fe5b0cb84", + "id": null, + "metadata": {}, + "name": "UsersCardQuery", + "operationKind": "query", + "text": "query UsersCardQuery {\n ...UsersTable_users\n}\n\nfragment UsersTable_users on Query {\n users {\n edges {\n user: node {\n email\n username\n createdAt\n role {\n name\n }\n }\n }\n }\n}\n" + } +}; + +(node as any).hash = "2b3e260c950dd1a6a1a6883ff6fb78c4"; + +export default node; diff --git a/app/src/pages/settings/__generated__/UsersTableQuery.graphql.ts b/app/src/pages/settings/__generated__/UsersTableQuery.graphql.ts deleted file mode 100644 index 3916140d94..0000000000 --- a/app/src/pages/settings/__generated__/UsersTableQuery.graphql.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * @generated SignedSource<<83f7e6d47fae194913c897d5da42be67>> - * @lightSyntaxTransform - * @nogrep - */ - -/* tslint:disable */ -/* eslint-disable */ -// @ts-nocheck - -import { ConcreteRequest, Query } from 'relay-runtime'; -export type UsersTableQuery$variables = Record; -export type UsersTableQuery$data = { - readonly users: { - readonly edges: ReadonlyArray<{ - readonly user: { - readonly createdAt: string; - readonly email: string; - readonly role: { - readonly name: string; - }; - readonly username: string | null; - }; - }>; - }; -}; -export type UsersTableQuery = { - response: UsersTableQuery$data; - variables: UsersTableQuery$variables; -}; - -const node: ConcreteRequest = (function(){ -var v0 = [ - { - "alias": null, - "args": null, - "concreteType": "UserConnection", - "kind": "LinkedField", - "name": "users", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "concreteType": "UserEdge", - "kind": "LinkedField", - "name": "edges", - "plural": true, - "selections": [ - { - "alias": "user", - "args": null, - "concreteType": "User", - "kind": "LinkedField", - "name": "node", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "email", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "username", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "createdAt", - "storageKey": null - }, - { - "alias": null, - "args": null, - "concreteType": "UserRole", - "kind": "LinkedField", - "name": "role", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "name", - "storageKey": null - } - ], - "storageKey": null - } - ], - "storageKey": null - } - ], - "storageKey": null - } - ], - "storageKey": null - } -]; -return { - "fragment": { - "argumentDefinitions": [], - "kind": "Fragment", - "metadata": null, - "name": "UsersTableQuery", - "selections": (v0/*: any*/), - "type": "Query", - "abstractKey": null - }, - "kind": "Request", - "operation": { - "argumentDefinitions": [], - "kind": "Operation", - "name": "UsersTableQuery", - "selections": (v0/*: any*/) - }, - "params": { - "cacheID": "3771d6178f1ad5275a38f8c9052a0508", - "id": null, - "metadata": {}, - "name": "UsersTableQuery", - "operationKind": "query", - "text": "query UsersTableQuery {\n users {\n edges {\n user: node {\n email\n username\n createdAt\n role {\n name\n }\n }\n }\n }\n}\n" - } -}; -})(); - -(node as any).hash = "0a975e18bb54ce2fb35993eb6dcfe029"; - -export default node; diff --git a/app/src/pages/settings/__generated__/UsersTable_users.graphql.ts b/app/src/pages/settings/__generated__/UsersTable_users.graphql.ts new file mode 100644 index 0000000000..a037884ae6 --- /dev/null +++ b/app/src/pages/settings/__generated__/UsersTable_users.graphql.ts @@ -0,0 +1,118 @@ +/** + * @generated SignedSource<<2f6a03d56a2b1c81095de0c7a7fceabc>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { Fragment, ReaderFragment } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type UsersTable_users$data = { + readonly users: { + readonly edges: ReadonlyArray<{ + readonly user: { + readonly createdAt: string; + readonly email: string; + readonly role: { + readonly name: string; + }; + readonly username: string | null; + }; + }>; + }; + readonly " $fragmentType": "UsersTable_users"; +}; +export type UsersTable_users$key = { + readonly " $data"?: UsersTable_users$data; + readonly " $fragmentSpreads": FragmentRefs<"UsersTable_users">; +}; + +const node: ReaderFragment = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "UsersTable_users", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "UserConnection", + "kind": "LinkedField", + "name": "users", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "UserEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": "user", + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "email", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "username", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "UserRole", + "kind": "LinkedField", + "name": "role", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null +}; + +(node as any).hash = "a87302705dfc7b091db312313f46ec4f"; + +export default node; diff --git a/src/phoenix/auth.py b/src/phoenix/auth.py index 609f262a79..6cec964a6e 100644 --- a/src/phoenix/auth.py +++ b/src/phoenix/auth.py @@ -39,7 +39,10 @@ def validate_password_format(password: str) -> None: raise ValueError("Password cannot contain whitespace characters") if not password.isascii(): raise ValueError("Password can contain only ASCII characters") + if not len(password) >= MIN_PASSWORD_LENGTH: + raise ValueError(f"Password must be at least {MIN_PASSWORD_LENGTH} characters long") EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+[.][^@\s]+\Z") NUM_ITERATIONS = 10_000 +MIN_PASSWORD_LENGTH = 4