diff --git a/kratos-admin-ui/Dockerfile b/kratos-admin-ui/Dockerfile index 106391e..539225e 100644 --- a/kratos-admin-ui/Dockerfile +++ b/kratos-admin-ui/Dockerfile @@ -3,8 +3,10 @@ WORKDIR /app ENV PATH /app/node_modules/.bin:$PATH COPY package.json ./ COPY package-lock.json ./ -RUN npm ci --legacy-peer-deps +COPY .npmrc ./ +RUN npm ci COPY . ./ +RUN npm run test RUN npm run build diff --git a/kratos-admin-ui/package-lock.json b/kratos-admin-ui/package-lock.json index ec434d5..35fd5ab 100644 --- a/kratos-admin-ui/package-lock.json +++ b/kratos-admin-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "kratos-admin-ui", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kratos-admin-ui", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@fluentui/react-components": "9.37.3", diff --git a/kratos-admin-ui/package.json b/kratos-admin-ui/package.json index 2f36b68..9e8c4af 100644 --- a/kratos-admin-ui/package.json +++ b/kratos-admin-ui/package.json @@ -1,6 +1,6 @@ { "name": "kratos-admin-ui", - "version": "1.1.0", + "version": "1.2.0", "private": true, "dependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", @@ -28,7 +28,7 @@ "scripts": { "start": "react-scripts start", "build": "react-scripts build", - "test": "react-scripts test", + "test": "react-scripts test --watchAll=false", "eject": "react-scripts eject", "proxy": "node cors-proxy.js &" }, diff --git a/kratos-admin-ui/src/components/messages/messagebar.tsx b/kratos-admin-ui/src/components/messages/messagebar.tsx new file mode 100644 index 0000000..cfd2b3f --- /dev/null +++ b/kratos-admin-ui/src/components/messages/messagebar.tsx @@ -0,0 +1,124 @@ +import { + MessageBar, + MessageBarActions, + MessageBarTitle, + MessageBarBody, + MessageBarGroup, + Button, + Link, + MessageBarIntent, +} from "@fluentui/react-components"; +import { DismissRegular } from "@fluentui/react-icons"; +import React, { useEffect, useState } from "react"; + +interface Message { + intent: MessageBarIntent; + title: string; + content?: JSX.Element; +} + +interface InternalMessage extends Message { + id: number; + removeAfterSeconds: number; +} + +type MessageConfig = { + message: Message; + removeAfterSeconds: number; +} + + +export class MessageService { + + private static _instance: MessageService; + + private eventQueue: InternalMessage[] = []; + private id: number = 0; + + public dispatchMessage(message: MessageConfig): void { + const msg: InternalMessage = { + id: this.id++, + content: message.message.content, + intent: message.message.intent, + title: message.message.title, + removeAfterSeconds: message.removeAfterSeconds + }; + + + this.eventQueue.push(msg) + window.dispatchEvent(new Event("new_event_dispatched")); + } + + public getAndCleanMessages(): InternalMessage[] { + const data = this.eventQueue; + this.eventQueue = [] + return data; + } + + private constructor() { + + } + + + + public static get Instance() { + return this._instance || (this._instance = new this()); + } + +} + +export function MessageBarComponent() { + + const [messages, setMessages] = useState([]); + const dismissMessage = (messageId: number) => + setMessages((s) => s.filter((entry) => entry.id !== messageId)); + + function handleWindowClick() { + const newMessages = MessageService.Instance.getAndCleanMessages(); + setMessages(messages.concat(newMessages)); + + + newMessages.forEach(newMessage => { + setTimeout(() => { + dismissMessage(newMessage.id) + }, newMessage.removeAfterSeconds * 1000) + }) + } + + useEffect(() => { + window.addEventListener('new_event_dispatched', handleWindowClick); + return () => { + window.removeEventListener("new_event_dispatched", handleWindowClick); + } + }, []) + + return ( + + {messages.map(({ intent, id, title, content }) => ( + + + {title} + {content} + + dismissMessage(id)} + aria-label="dismiss" + appearance="transparent" + icon={} + /> + } + /> + + ))} + + ) + +} + + diff --git a/kratos-admin-ui/src/components/multiline/multiline.tsx b/kratos-admin-ui/src/components/multiline/multiline.tsx new file mode 100644 index 0000000..05dd810 --- /dev/null +++ b/kratos-admin-ui/src/components/multiline/multiline.tsx @@ -0,0 +1,88 @@ +import { Button, Input, Tooltip } from "@fluentui/react-components"; +import { Add12Filled, Delete12Filled } from "@fluentui/react-icons"; +import { useEffect, useState } from "react" +import { FluentUIInputDataType } from "../../service/schema-service"; + + +interface MultilineEditProps { + defaultData?: any[] + datatype: FluentUIInputDataType; + dataChanged(any: any[]): void; + name: string; +} + +export function MultilineEdit(props: MultilineEditProps) { + + const [data, setData] = useState([]); + + useEffect(() => { + if (props.defaultData && props.defaultData.length > 0) { + if (JSON.stringify(data) !== JSON.stringify(props.defaultData)) { + setData(props.defaultData) + } + } else { + setData([""]) + } + }, []) + + + return ( + <> + {data.map((value, index) => { + return ( +
+ { + data[index] = inputData.value + props.dataChanged(data) + }} + defaultValue={value} + name={props.name + "_" + index} + > +
+ + + + + + +
+
+ ) + })} + + ) + +} \ No newline at end of file diff --git a/kratos-admin-ui/src/components/sessions/list-sessions.tsx b/kratos-admin-ui/src/components/sessions/list-sessions.tsx index 7db6816..529e7b7 100644 --- a/kratos-admin-ui/src/components/sessions/list-sessions.tsx +++ b/kratos-admin-ui/src/components/sessions/list-sessions.tsx @@ -4,6 +4,7 @@ import { IdentityApi, Session, SessionAuthenticationMethod, SessionDevice } from import React from "react"; import { getKratosConfig } from "../../config"; import { ToolbarItem } from "../../sites/identities/identies"; +import { MessageService } from "../messages/messagebar"; interface ListSessionsProps { identity_id: string; @@ -39,6 +40,16 @@ export class ListSessions extends React.Component { + const map = await SchemaService.getTableDetailListModelFromKratosIdentity(identity); + const traits: IdentityTraits = {} + + for (const [key, value] of Object.entries(map)) { + if (key !== "key") { + schemaFields.forEach(f => { + if (f.name === key) { + if (f.parentName) { + if (!traits[f.parentName]) { + traits[f.parentName] = {} + } + traits[f.parentName][f.name] = value + } else { + traits[f.name] = value + } + } + }); + } + } + return traits; +} + + + +async function performAction(modi: Modi, values: ValueObject, identity?: Identity, schemaId?: string): Promise> { + const kratosConfig = await getKratosConfig(); + const adminAPI = new IdentityApi(kratosConfig.adminConfig); + + if (modi === "edit") { + const updatededIdenity = await adminAPI.updateIdentity( + { + id: identity?.id!, + updateIdentityBody: { + schema_id: identity?.schema_id!, + traits: values.traits, + state: values.state, + metadata_public: identity?.metadata_public, + metadata_admin: identity?.metadata_admin + } + }) + MessageService.Instance.dispatchMessage({ + removeAfterSeconds: 2, + message: { + title: "identity updated", + intent: "success" + } + }) + + return updatededIdenity + } else { + const newIdenity = await adminAPI.createIdentity({ + createIdentityBody: { + schema_id: schemaId!, + traits: values.traits, + metadata_admin: kratosConfig.adminConfig.basePath, + metadata_public: kratosConfig.publicConfig.basePath + } + }) + MessageService.Instance.dispatchMessage({ + removeAfterSeconds: 2, + message: { + title: "new identity created", + intent: "success" + } + }) + return newIdenity; + } +} + +export function EditTraits(props: EditTraitsProps) { + + const [identity] = useState(props.identity); + const [schemaFields, setSchemaFields] = useState(); + const [values, setValues] = useState() + const [errorText, setErrorText] = useState() + + const history = useHistory(); + + useEffect(() => { + async function prepare() { + let newSchemaFields: SchemaField[] = [] + let valueObject: ValueObject; + + if (identity && props.modi === "edit") { + newSchemaFields = await SchemaService.getSchemaFieldsFromIdentity(identity); + valueObject = { + state: identity.state!, + traits: await fillTraits(identity, newSchemaFields), + publicMetadata: identity.metadata_public, + adminMedataData: identity.metadata_admin + } + } else if (props.schema && props.modi === "new") { + newSchemaFields = SchemaService.getSchemaFields(props.schema) + valueObject = { + state: "active", + traits: {}, + adminMedataData: {}, + publicMetadata: {} + } + } else { + throw new Error("Either identity (modi=edit) or schema (modi=new) has to be definied") + } + + // array value compare + if (JSON.stringify(newSchemaFields) !== JSON.stringify(schemaFields)) { + setSchemaFields(newSchemaFields); + } + + // array value compare + if (JSON.stringify(valueObject) !== JSON.stringify(values)) { + setValues(valueObject); + } + } + + prepare() + }) + + return ( +
+ Standard Properties + + {props.modi === "edit" && + <> + + + + + } + + {values && + { + const newValues = values; + newValues.state = data.checked ? "active" : "inactive"; + setValues(newValues); + }} + > + } + + + Custom Traits + {schemaFields && values && schemaFields.map((elem, key) => { + return ( +
+ { + setValues(values) + }} + > +
+ ) + })} + {!errorText ||
{errorText}
} +
+
+ + +
+
+
+ ) + +} \ No newline at end of file diff --git a/kratos-admin-ui/src/components/traits/metadata-renderer.tsx b/kratos-admin-ui/src/components/traits/metadata-renderer.tsx new file mode 100644 index 0000000..df1a39d --- /dev/null +++ b/kratos-admin-ui/src/components/traits/metadata-renderer.tsx @@ -0,0 +1,56 @@ +import { MultilineEdit } from "../multiline/multiline" +import { Text } from "@fluentui/react-components" +import { MetaData } from "./edit-traits" +import { useEffect } from "react"; + +interface MetadataRendererProps { + publicMetadata?: MetaData; + adminMetadata?: MetaData; +} + +export function MetadataRenderer(props: MetadataRendererProps) { + + useEffect(()=> { + + }, []) + + return <> +
+ Public Metadata + { + console.log({ data }) + }} + name="public_metadata" + defaultData={[]} + > +
+ +
+ Admin Metadata + { + console.log({ data }) + }} + name="admin_metadata" + defaultData={[]} + > +
+ +} \ No newline at end of file diff --git a/kratos-admin-ui/src/components/traits/render-field.tsx b/kratos-admin-ui/src/components/traits/render-field.tsx new file mode 100644 index 0000000..cc163d3 --- /dev/null +++ b/kratos-admin-ui/src/components/traits/render-field.tsx @@ -0,0 +1,67 @@ +import { Label } from "@fluentui/react-components" +import { SchemaField, mapSchemaDataType } from "../../service/schema-service" +import { ValueObject } from "./edit-traits" +import { SingleField } from "./single-field" +import { MultilineEdit } from "../multiline/multiline" + +interface RenderTraitFieldProps { + schemaField: SchemaField, + fieldValues: ValueObject + setValues(values: ValueObject): void +} + +function getDefaultValue(schemaField: SchemaField, values: ValueObject): any[] { + if (schemaField.type === "array") { + if (schemaField.parentName) { + if (values.traits[schemaField.parentName] && values.traits[schemaField.parentName][schemaField.name]) { + return values.traits[schemaField.parentName][schemaField.name]; + } + } else { + if (values.traits[schemaField.name]) { + return values.traits[schemaField.name]; + } + } + return [] + } + throw new Error("Should not be called as non array object!") +} + +export function RenderTraitField(props: RenderTraitFieldProps) { + + return ( + <> + {props.schemaField.type !== "array" && + { + props.setValues(values) + }} + > + } + {props.schemaField.type === "array" && + <> + + { + if (props.schemaField.parentName) { + if (!props.fieldValues.traits[props.schemaField.parentName]) { + props.fieldValues.traits[props.schemaField.parentName] = {} + } + props.fieldValues.traits[props.schemaField.parentName][props.schemaField.name] = data + } else { + props.fieldValues.traits[props.schemaField.name] = data + } + props.setValues(props.fieldValues) + }} + datatype={mapSchemaDataType(props.schemaField.format)} + name={props.schemaField.name} + > + + } + + + ) + +} \ No newline at end of file diff --git a/kratos-admin-ui/src/components/traits/single-field.tsx b/kratos-admin-ui/src/components/traits/single-field.tsx new file mode 100644 index 0000000..961a55d --- /dev/null +++ b/kratos-admin-ui/src/components/traits/single-field.tsx @@ -0,0 +1,57 @@ +import { Field, Input } from "@fluentui/react-components" +import { SchemaField, mapSchemaDataType } from "../../service/schema-service" +import { ValueObject } from "./edit-traits" + +interface SingleFieldProps { + schemaField: SchemaField, + fieldValues: ValueObject + setValues(values: ValueObject): void +} + +function getDefaultValue(schemaField: SchemaField, values: ValueObject): string { + if (schemaField.type === "array") { + return "array value!" + } else { + if (schemaField.parentName) { + if (values.traits[schemaField.parentName] && values.traits[schemaField.parentName][schemaField.name]) { + return values.traits[schemaField.parentName][schemaField.name]; + } + } else { + if (values.traits[schemaField.name]) { + return values.traits[schemaField.name]; + } + } + } + return ""; +} + +export function SingleField(props: SingleFieldProps) { + + return ( + <> + + { + if (props.fieldValues) { + if (props.schemaField.parentName) { + if (!props.fieldValues.traits[props.schemaField.parentName]) { + props.fieldValues.traits[props.schemaField.parentName] = {} + } + props.fieldValues.traits[props.schemaField.parentName][props.schemaField.name] = value.value + } else { + props.fieldValues.traits[props.schemaField.name] = value.value + } + props.setValues(props.fieldValues) + } + }} + defaultValue={getDefaultValue(props.schemaField, props.fieldValues)} + type={mapSchemaDataType(props.schemaField.format)} + > + + + ) + +} \ No newline at end of file diff --git a/kratos-admin-ui/src/index.tsx b/kratos-admin-ui/src/index.tsx index 2924974..c0ceb35 100644 --- a/kratos-admin-ui/src/index.tsx +++ b/kratos-admin-ui/src/index.tsx @@ -1,10 +1,11 @@ import React, { Suspense } from 'react'; -import {createRoot} from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; import { Route, BrowserRouter as Router, Switch, Redirect } from 'react-router-dom'; import HeaderComponent from './components/header/header'; import FooterComponent from './components/footer/footer'; import './index.scss'; import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { MessageBarComponent } from './components/messages/messagebar'; const IdentitiesSite = React.lazy(() => import('./sites/identities/identies')); const CreateIdentitySite = React.lazy(() => import("./sites/identities/create/create")); @@ -12,39 +13,40 @@ const ViewIdentitySite = React.lazy(() => import("./sites/identities/view/view") const EditIdentitySite = React.lazy(() => import("./sites/identities/edit/edit")); const OverviewSite = React.lazy(() => import("./sites/overview")); -const container = document.getElementById('root') +const container = document.getElementById('root') const root = createRoot(container!) root.render( - - -
- -
- Seite wird geladen ...
}> - - - - - - - - - - - - - - - - - - - -
- + + +
+ +
+ + Seite wird geladen ...
}> + + + + + + + + + + + + + + + + + + +
-
-
+ + +
+
, ); \ No newline at end of file diff --git a/kratos-admin-ui/src/service/schema-service.test.ts b/kratos-admin-ui/src/service/schema-service.test.ts new file mode 100644 index 0000000..41eda23 --- /dev/null +++ b/kratos-admin-ui/src/service/schema-service.test.ts @@ -0,0 +1,323 @@ +import { SchemaField, SchemaService } from "./schema-service" + +describe("test getSchemaFields", () => { + + // https://github.com/ory/kratos/blob/master/contrib/quickstart/kratos/email-password/identity.schema.json + test("test email-password schema", () => { + const schema = { + "$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "E-Mail", + "minLength": 3, + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + } + }, + "verification": { + "via": "email" + }, + "recovery": { + "via": "email" + } + } + }, + "name": { + "type": "object", + "properties": { + "first": { + "title": "First Name", + "type": "string" + }, + "last": { + "title": "Last Name", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + } + } + + const fields: SchemaField[] = SchemaService.getSchemaFields(schema); + + expect(fields.length).toBe(3); + + expect(fields[0].parentName).toBe(undefined) + expect(fields[0].name).toBe("email") + expect(fields[0].title).toBe("E-Mail") + expect(fields[0].type).toBe("string") + + expect(fields[1].parentName).toBe("name") + expect(fields[1].name).toBe("first") + expect(fields[1].title).toBe("First Name") + expect(fields[1].type).toBe("string") + + expect(fields[2].parentName).toBe("name") + expect(fields[2].name).toBe("last") + expect(fields[2].title).toBe("Last Name") + expect(fields[2].type).toBe("string") + + + }) + + test("test schema required fields", () => { + const schema = { + "$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "E-Mail", + "minLength": 3, + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + } + }, + "verification": { + "via": "email" + }, + "recovery": { + "via": "email" + } + } + }, + "name": { + "type": "object", + "properties": { + "first": { + "title": "First Name", + "type": "string" + }, + "last": { + "title": "Last Name", + "type": "string" + } + } + } + }, + "required": ["email"], + "additionalProperties": false + } + } + } + + const fields: SchemaField[] = SchemaService.getSchemaFields(schema); + + expect(fields.length).toBe(3); + + expect(fields[0].parentName).toBe(undefined) + expect(fields[0].name).toBe("email") + expect(fields[0].title).toBe("E-Mail") + expect(fields[0].required).toBe(true) + + }) + + test("test array properties schema", () => { + const schema = { + "$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "emails": { + "type": "array", + "items": [ + { + "type": "string", + "format": "email", + "title": "E-Mail", + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + }, + "webauthn": { + "identifier": true + }, + "totp": { + "account_name": true + } + }, + "recovery": { + "via": "email" + }, + "verification": { + "via": "email" + } + }, + "maxLength": 320 + } + ] + } + }, + "required": ["emails"], + "additionalProperties": false + } + } + } + + const fields: SchemaField[] = SchemaService.getSchemaFields(schema); + + expect(fields.length).toBe(1); + expect(fields[0].type).toBe("array"); + expect(fields[0].subType).toBe("string"); + expect(fields[0].title).toBe("E-Mail"); + }) + + test("test array properties mixed schema with array of items", () => { + const schema = { + "$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "emails": { + "type": "array", + "items": [ + { + "type": "string", + "format": "email", + "title": "E-Mail", + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + }, + "webauthn": { + "identifier": true + }, + "totp": { + "account_name": true + } + }, + "recovery": { + "via": "email" + }, + "verification": { + "via": "email" + } + }, + "maxLength": 320 + } + ] + }, + "name": { + "type": "object", + "properties": { + "first": { + "title": "First Name", + "type": "string" + }, + "last": { + "title": "Last Name", + "type": "string" + } + } + } + }, + "required": ["emails", "name"], + "additionalProperties": false + } + } + } + + const fields: SchemaField[] = SchemaService.getSchemaFields(schema); + + expect(fields.length).toBe(3); + expect(fields[0].type).toBe("array"); + expect(fields[0].subType).toBe("string"); + expect(fields[1].type).toBe("string"); + expect(fields[2].type).toBe("string"); + }) + + test("test array properties mixed schema with single item", () => { + const schema = { + "$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "emails": { + "type": "array", + "items": { + "type": "string", + "format": "email", + "title": "E-Mail", + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + }, + "webauthn": { + "identifier": true + }, + "totp": { + "account_name": true + } + }, + "recovery": { + "via": "email" + }, + "verification": { + "via": "email" + } + }, + "maxLength": 320 + } + }, + "name": { + "type": "object", + "properties": { + "first": { + "title": "First Name", + "type": "string" + }, + "last": { + "title": "Last Name", + "type": "string" + } + } + } + }, + "required": ["emails", "name"], + "additionalProperties": false + } + } + } + + const fields: SchemaField[] = SchemaService.getSchemaFields(schema); + + expect(fields.length).toBe(3); + expect(fields[0].type).toBe("array"); + expect(fields[0].subType).toBe("string"); + expect(fields[1].type).toBe("string"); + expect(fields[2].type).toBe("string"); + }) +}) \ No newline at end of file diff --git a/kratos-admin-ui/src/service/schema-service.ts b/kratos-admin-ui/src/service/schema-service.ts index c1920cd..d986400 100644 --- a/kratos-admin-ui/src/service/schema-service.ts +++ b/kratos-admin-ui/src/service/schema-service.ts @@ -1,13 +1,38 @@ import { Identity, IdentityApi, IdentitySchemaContainer } from "@ory/kratos-client"; import { getKratosConfig } from "../config"; +export type FluentUIInputDataType = "number" | "time" | "text" | "tel" | "url" | "email" | "date" | "datetime-local" | "month" | "password" | "week" + +export function mapSchemaDataType(type: string): FluentUIInputDataType { + switch (type) { + case "email": + return "email"; + case "number": + return "number"; + case "time": + return "time"; + case "date": + return "date" + default: + return "text"; + } +} + + export interface SchemaField { name: string; title: string; + type: string; + subType: string; parentName?: string; + required: boolean; + format: string; } -export interface DetailListModel { +export interface TableDetailListModel { + state: string; + schema: string; + verifiable_addresses: string; [key: string]: string; } @@ -30,17 +55,20 @@ export class SchemaService { }) } - static async getSchemaJSON(schema: string): Promise { - if (this.schema_map.has(schema)) { + static async getSchemaJSON(schemaId: string): Promise { + if (this.schema_map.has(schemaId)) { return new Promise(resolve => { - resolve(this.schema_map.get(schema)) + resolve(this.schema_map.get(schemaId)) }) } else { const config = await getKratosConfig() const publicAPI = new IdentityApi(config.publicConfig) - const schemaResponse = await publicAPI.getIdentitySchema({ id: schema }); - this.extractSchemas([schemaResponse.data]) - return this.schema_map.get(schema) + const schemaResponse = await publicAPI.getIdentitySchema({ id: schemaId }); + this.extractSchemas([{ + schema: schemaResponse.data, + id: schemaId + }]) + return this.schema_map.get(schemaId) } } @@ -52,63 +80,110 @@ export class SchemaService { let array: SchemaField[] = []; array = array.concat(this.getSchemaFieldsInternal(schema.properties.traits)) + + // set required flag + if (schema.properties.traits.required) { + const required = schema.properties.traits.required; + for (const requiredField of required) { + for (const elm of array) { + if (elm.name === requiredField) { + elm.required = true + } + } + } + } + return array; } - static getSchemaFieldsInternal(schema: any, parentName?: string): SchemaField[] { + private static getSchemaFieldsInternal(schema: any, parentName?: string): SchemaField[] { let array: SchemaField[] = []; const properties = schema.properties; for (const key of Object.keys(properties)) { if (properties[key].properties) { array = array.concat(this.getSchemaFieldsInternal(properties[key], key)) } else { - array.push({ + const elem: SchemaField = { name: key, title: properties[key].title, - parentName: parentName - }); + parentName: parentName, + required: false, + type: properties[key].type, + subType: properties[key].type, + format: properties[key].format ? properties[key].format : "text" + } + + if (elem.type === "array") { + if (properties[key].items) { + let item; + if (Array.isArray(properties[key].items) && properties[key].items.length > 0) { + item = properties[key].items[0]; + } else { + item = properties[key].items; + } + elem.subType = item.type; + elem.title = item.title; + } + } + + array.push(elem); } } - return array; } - static mapKratosIdentity(data: Identity, fields: SchemaField[]): DetailListModel { - return this.mapKratosIdentites([data], fields)[0]; + static async getSchemaFieldsFromIdentity(identity: Identity): Promise { + const schema = await this.getSchemaJSON(identity.schema_id); + return this.getSchemaFields(schema) + } + + static async getTableDetailListModelFromKratosIdentity(data: Identity): Promise { + const array = await this.getTableDetailListModelFromKratosIdentities([data]); + return array[0]; } - static mapKratosIdentites(data: Identity[], fields: SchemaField[]): DetailListModel[] { - return data.map(element => { + static async getTableDetailListModelFromKratosIdentities(data: Identity[]): Promise { + const typeList: TableDetailListModel[] = [] + for (const element of data) { const traits: any = element.traits; - const type: any = { key: element.id }; + const type: TableDetailListModel = { + key: element.id, + state: element.state!, + schema: element.schema_id, + verifiable_addresses: element.verifiable_addresses?.map(e => e.value).join(", ")! + }; - fields.forEach(f => { + const fields = await this.getSchemaFieldsFromIdentity(element); + + for (const f of fields) { if (f.parentName) { type[f.name] = traits[f.parentName]?.[f.name] } else { type[f.name] = traits[f.name] } - }) - - return type; - }) + } + typeList.push(type) + } + return typeList; } - static extractSchemas(identitySchemas: IdentitySchemaContainer[]) { + private static extractSchemas(identitySchemas: IdentitySchemaContainer[]) { if (identitySchemas.length === 0) { this.schema_ids.push("default") } identitySchemas.forEach(schema => { - if (this.schema_ids.indexOf(schema.id!) === -1) { - this.schema_ids.push(schema.id!); - } + if (schema.schema) { + if (this.schema_ids.indexOf(schema.id!) === -1) { + this.schema_ids.push(schema.id!); + } - // since v0.11 the schema is base64 encoded - //const parsedJSON = JSON.parse(window.atob(schema.schema + "")) - //this.schema_map.set(schema.id!, parsedJSON) + // since v0.11 the schema is base64 encoded + //const parsedJSON = JSON.parse(window.atob(schema.schema + "")) + //this.schema_map.set(schema.id!, parsedJSON) - // since v0.13 the schema isnt base64 encoded anymore - this.schema_map.set(schema.id!, schema.schema) + // since v0.13 the schema isnt base64 encoded anymore + this.schema_map.set(schema.id!, schema.schema) + } }); } } \ No newline at end of file diff --git a/kratos-admin-ui/src/sites/identities/create/create.tsx b/kratos-admin-ui/src/sites/identities/create/create.tsx index 68d0a70..c577cd6 100644 --- a/kratos-admin-ui/src/sites/identities/create/create.tsx +++ b/kratos-admin-ui/src/sites/identities/create/create.tsx @@ -1,22 +1,14 @@ -import { Button, Title1, Select, Input, Field } from "@fluentui/react-components"; -import { IdentityApi } from "@ory/kratos-client"; +import { Title1, Select, Field } from "@fluentui/react-components"; import React from "react"; import { withRouter } from "react-router-dom"; -import { getKratosConfig } from "../../../config"; -import { SchemaField, SchemaService } from "../../../service/schema-service"; +import { SchemaService } from "../../../service/schema-service"; import "./create.scss" +import { EditTraits } from "../../../components/traits/edit-traits"; interface CreateIdentitySiteState { schemaOptions: string[]; - schema: object; + schema: any; schemaName: string; - schemaFields: SchemaField[] - errorText?: string; -} - - -interface Identity { - [key: string]: any; } class CreateIdentitySite extends React.Component { @@ -24,13 +16,9 @@ class CreateIdentitySite extends React.Component { state: CreateIdentitySiteState = { schemaOptions: [], schema: {}, - schemaFields: [], - schemaName: "", - errorText: undefined + schemaName: "" } - identity: Identity = {}; - componentDidMount() { SchemaService.getSchemaIDs().then(data => { this.setState({ @@ -52,91 +40,43 @@ class CreateIdentitySite extends React.Component { SchemaService.getSchemaJSON(schema).then(data => { this.setState({ schema: data, - schemaFields: SchemaService.getSchemaFields(data), schemaName: schema }) }); } } - setValue(field: SchemaField, value: string | undefined) { - if (value) { - if (field && field.parentName) { - if (!this.identity[field.parentName]) { - this.identity[field.parentName] = {} - } - this.identity[field.parentName][field.name] = value - } else { - this.identity[field.name] = value - } - } - } - - create() { - getKratosConfig().then(config => { - const adminAPI = new IdentityApi(config.adminConfig); - adminAPI.createIdentity({ - createIdentityBody: { - schema_id: this.state.schemaName, - traits: this.identity, - metadata_admin: config.adminConfig.basePath, - metadata_public: config.publicConfig.basePath - } - }).then(data => { - this.props.history.push("/identities"); - }).catch(err => { - this.setState({ - errorText: JSON.stringify(err.response.data.error) - }) - }) - }) - } - render() { return (
Create Identity -

Please select the scheme for which you want to create a new identity:

- + + +
{JSON.stringify(this.state.schema, null, 2)}

- {!this.state.errorText ||
{this.state.errorText}
} -
-
- {this.state.schemaFields.map((elem, key) => { - return (
-
- - { - this.setValue(elem, value.value) - }} /> - -
-
) - })} -
-
-
- - -
-
-
+ + {this.state.schema.properties && + + }
) } diff --git a/kratos-admin-ui/src/sites/identities/edit/edit.tsx b/kratos-admin-ui/src/sites/identities/edit/edit.tsx index 241ace4..6929f00 100644 --- a/kratos-admin-ui/src/sites/identities/edit/edit.tsx +++ b/kratos-admin-ui/src/sites/identities/edit/edit.tsx @@ -1,126 +1,36 @@ -import { Button, Input, Title1, Title2, Field } from "@fluentui/react-components"; -import { Identity, IdentityState, IdentityApi } from "@ory/kratos-client"; +import { Title1, Title2 } from "@fluentui/react-components"; +import { Identity, IdentityApi } from "@ory/kratos-client"; import React from "react"; import { withRouter } from "react-router-dom"; import { ListSessions } from "../../../components/sessions/list-sessions"; import { getKratosConfig } from "../../../config"; -import { SchemaField, SchemaService } from "../../../service/schema-service"; +import { EditTraits } from "../../../components/traits/edit-traits"; interface EditIdentityState { identity?: Identity - schemaFields: SchemaFieldWithValue[] - errorText?: string - traits: Traits; -} - -interface SchemaFieldWithValue extends SchemaField { - value: any; -} - -interface Traits { - [key: string]: any; } class EditIdentitySite extends React.Component { state: EditIdentityState = { - schemaFields: [], - traits: {} } componentDidMount() { this.mapEntity(this.props.match.params.id).then(() => { }) } - async mapEntity(id: any): Promise { - const array: SchemaFieldWithValue[] = [] + async mapEntity(id: any): Promise { const config = await getKratosConfig() const adminAPI = new IdentityApi(config.adminConfig); const entity = await adminAPI.getIdentity({ id: id }); - await SchemaService.getSchemaIDs() - const schema = await SchemaService.getSchemaJSON(entity.data.schema_id); - const schemaFields = SchemaService.getSchemaFields(schema); - const map = SchemaService.mapKratosIdentity(entity.data, schemaFields); - const traits: Traits = {} - for (const [key, value] of Object.entries(map)) { - if (key !== "key") { - schemaFields.forEach(f => { - if (f.name === key) { - array.push({ - name: key, - value: value, - title: f.title, - parentName: f.parentName - }) - - if (f.parentName) { - if (!traits[f.parentName]) { - traits[f.parentName] = {} - } - traits[f.parentName][key] = value; - } else { - traits[key] = value; - } - } - }); - } - } this.setState({ - identity: entity.data, - schemaFields: array, - traits: traits + identity: entity.data }) - - return array; } - patchField(field: SchemaFieldWithValue, value: string | undefined) { - if (value) { - const traits = this.state.traits; - if (field.parentName) { - traits[field.parentName][field.name] = value - } else { - traits[field.name] = value; - } - this.setState({ - traits: traits - }) - } - } - - save() { - if (this.state.identity) { - getKratosConfig().then(config => { - const adminAPI = new IdentityApi(config.adminConfig); - adminAPI.updateIdentity( - { - id: this.state.identity?.id!, - updateIdentityBody: { - schema_id: this.state.identity?.schema_id!, - traits: this.state.traits, - state: IdentityState.Active, - metadata_public: this.state.identity?.metadata_public, - metadata_admin: this.state.identity?.metadata_admin - } - }).then(data => { - this.props.history.push("/identities/" + this.state.identity?.id + "/view") - }).catch(err => { - this.setState({ errorText: JSON.stringify(err.response.data.error) }) - }) - }) - } - } - - arrayToObject(fields: SchemaFieldWithValue[]): any { - const obj: any = {} - fields.forEach(field => { - obj[field.name] = field.value - }); - return obj; - } render() { return ( @@ -128,31 +38,11 @@ class EditIdentitySite extends React.Component { Edit Identity {!this.state.identity ||
- {!this.state.errorText ||
{this.state.errorText}
}
- {this.state.schemaFields.map((elem, key) => { - return ( -
- - { - this.patchField(elem, value.value) - }} - defaultValue={elem.value} - > - -
- ) - })} -
-
- - -
-
+
Sessions diff --git a/kratos-admin-ui/src/sites/identities/identies.tsx b/kratos-admin-ui/src/sites/identities/identies.tsx index fe885ce..19cec6b 100644 --- a/kratos-admin-ui/src/sites/identities/identies.tsx +++ b/kratos-admin-ui/src/sites/identities/identies.tsx @@ -1,10 +1,10 @@ -import { Title1, Toolbar, ToolbarButton, Table, TableHeader, TableRow, TableHeaderCell, TableBody, TableCell, TableSelectionCell } from "@fluentui/react-components"; -import { IdentityApi } from "@ory/kratos-client"; +import { Title1, Toolbar, ToolbarButton, DataGrid, DataGridHeader, DataGridBody, DataGridRow, DataGridHeaderCell, DataGridCell, TableColumnDefinition, createTableColumn, TableRowId } from "@fluentui/react-components"; +import { Identity, IdentityApi } from "@ory/kratos-client"; import React from "react"; import { withRouter } from "react-router-dom"; import { getKratosConfig } from "../../config"; -import { DetailListModel, SchemaField, SchemaService } from "../../service/schema-service"; import { ArrowClockwiseRegular, ClipboardEditRegular, ContentViewRegular, DeleteRegular, MailRegular, NewRegular } from "@fluentui/react-icons"; +import { MessageService } from "../../components/messages/messagebar"; export interface ToolbarItem { text: string; @@ -13,26 +13,70 @@ export interface ToolbarItem { icon: any; } -interface TableHeaderItem { - key: string; - name: string; - fieldName: string; -} - interface IdentitiesState { commandBarItems: ToolbarItem[] - selectedRows: any[] - listItems: DetailListModel[] - listColumns: TableHeaderItem[] + tableItems: IdentityTableItem[] + selectedRows: TableRowId[] +} + +interface IdentityTableItem { + id: string; + state: string; + schema: string; + verifiable_addresses: string; } -const ID_COLUMN = { key: 'id_column', name: 'ID', fieldName: 'key' } +const columns: TableColumnDefinition[] = [ + createTableColumn({ + columnId: "verifiable_addresses", + renderHeaderCell: () => { + return "Verifiable Address" + }, + renderCell: (item) => { + return {item.verifiable_addresses} + }, + compare: (a, b) => a.verifiable_addresses.localeCompare(b.verifiable_addresses) + }), + createTableColumn({ + columnId: "state", + renderHeaderCell: () => { + return "State" + }, + renderCell: (item) => { + return ( + + {item.state} + + ) + }, + compare: (a, b) => a.state.localeCompare(b.state) + }), + createTableColumn({ + columnId: "schema", + renderHeaderCell: () => { + return "Schema" + }, + renderCell: (item) => { + return {item.schema} + }, + compare: (a, b) => a.schema.localeCompare(b.schema) + }), + createTableColumn({ + columnId: "id", + renderHeaderCell: () => { + return "ID" + }, + renderCell: (item) => { + return {item.id} + }, + compare: (a, b) => a.id.localeCompare(b.id) + }), +] class IdentitiesSite extends React.Component { state: IdentitiesState = { commandBarItems: this.getCommandbarItems(0), - listItems: [], - listColumns: [ID_COLUMN], + tableItems: [], selectedRows: [] } @@ -45,23 +89,6 @@ class IdentitiesSite extends React.Component { }) } - private mapListColumns(fields: SchemaField[]): TableHeaderItem[] { - if (fields.length === 0) { - return [ID_COLUMN]; - } else { - const array: TableHeaderItem[] = []; - fields.forEach(field => { - array.push({ - key: "column_" + field.name, - fieldName: field.name, - name: field.title - }); - }) - array.push(ID_COLUMN) - return array; - } - } - private getCommandbarItems(localCount: number): ToolbarItem[] { const array: ToolbarItem[] = [] @@ -117,35 +144,68 @@ class IdentitiesSite extends React.Component { } private refreshData(showBanner: boolean) { - this.refreshDataInternal(showBanner).then(() => { }) + this.refreshDataInternal(showBanner).then(() => { + }) } private async refreshDataInternal(showBanner: boolean) { const adminIdentitesReturn = await this.api!.listIdentities(); if (adminIdentitesReturn) { - const ids = await SchemaService.getSchemaIDs() - const schemaJson = await SchemaService.getSchemaJSON(ids[0]) - const fields = SchemaService.getSchemaFields(schemaJson) - this.setState({ - listItems: SchemaService.mapKratosIdentites(adminIdentitesReturn.data, fields), - listColumns: this.mapListColumns(fields), - commandBarItems: this.getCommandbarItems(0), - selectedRows: [] + tableItems: this.mapIdentitysToTable(adminIdentitesReturn.data) + }) + } + + if (showBanner) { + MessageService.Instance.dispatchMessage({ + removeAfterSeconds: 2, + message: { + title: "identities refreshed", + intent: "success" + } }) } } + private mapIdentitysToTable(identities: Identity[]): IdentityTableItem[] { + return identities.map(identity => { + return { + id: identity.id, + state: identity.state?.toString()!, + schema: identity.schema_id, + verifiable_addresses: identity.verifiable_addresses?.map(e => e.value).join(", ")! + } + }); + } + private deleteSelected() { const values = this.state.selectedRows; const promises: Promise[] = []; values.forEach(val => { promises.push(this.api!.deleteIdentity({ - id: val + id: val + "" })) }); Promise.all(promises).then(() => { this.refreshData(false); + MessageService.Instance.dispatchMessage({ + removeAfterSeconds: 2, + message: { + title: "selected identites deleted", + intent: "success" + } + }) + }).catch(err => { + MessageService.Instance.dispatchMessage({ + removeAfterSeconds: 5, + message: { + title: "failed to delete identites", + intent: "error", + content:
+ See console logs for more informations +
+ } + }) }) } @@ -155,24 +215,32 @@ class IdentitiesSite extends React.Component { values.forEach(val => { promises.push(this.api!.createRecoveryLinkForIdentity({ createRecoveryLinkForIdentityBody: { - identity_id: val + identity_id: val + "" } })) }); Promise.all(promises).then(() => { + MessageService.Instance.dispatchMessage({ + removeAfterSeconds: 2, + message: { + title: "selected identites recovered", + intent: "success" + } + }) + }).catch(err => { + MessageService.Instance.dispatchMessage({ + removeAfterSeconds: 5, + message: { + title: "failed to recover identites", + intent: "error", + content:
+ See console logs for more informations +
+ } + }) }) } - private getTableSelectionCellCheckedValue(): 'mixed' | boolean { - if (this.state.selectedRows.length === 0) { - return false; - } - if (this.state.selectedRows.length === this.state.listItems.length) { - return true; - } - return "mixed"; - } - render() { return (
@@ -191,75 +259,54 @@ class IdentitiesSite extends React.Component { } - - - - { - if (e.target instanceof HTMLInputElement) { - if (e.target.checked) { - const array = this.state.listItems.map(i => i["key"]); - this.setState({ - selectedRows: array, - commandBarItems: this.getCommandbarItems(array.length) - }) - } else { - this.setState({ - selectedRows: [], - commandBarItems: this.getCommandbarItems(0) - }) - } - } - }} - checked={this.getTableSelectionCellCheckedValue()} - > - {this.state.listColumns.map(item => { - return ( - - {item.name} - - ) - })} - - - - {this.state.listItems.map(item => { - return ( - this.props.history.push("/identities/" + item.key + "/view")}> - { - if (e.target instanceof HTMLInputElement) { - if (e.target.checked) { - const array = [...this.state.selectedRows, item.key] - this.setState({ - selectedRows: array, - commandBarItems: this.getCommandbarItems(array.length) - }) - } else { - const array = this.state.selectedRows.filter(it => it !== item.key) - this.setState({ - selectedRows: array, - commandBarItems: this.getCommandbarItems(array.length) - }) - } - } - }} - checked={this.state.selectedRows.indexOf(item.key) > -1} - /> - {this.state.listColumns.map(column => { - return ( - - {item[column.fieldName]} - - ) - })} - - ) - })} - -
-

{this.state.selectedRows.length} Item(s) selected

+ item.id} + onSelectionChange={(e, data) => { + this.setState({ + commandBarItems: this.getCommandbarItems(data.selectedItems.size), + selectedRows: Array.from(data.selectedItems.values()) + }) + }} + columnSizingOptions={{ + id: { + defaultWidth: 300 + }, + state: { + defaultWidth: 60, + minWidth: 60 + }, + schema: { + defaultWidth: 80 + }, + verifiable_addresses: { + defaultWidth: 300 + } + }} + > + + + {({ renderHeaderCell }) => ( + {renderHeaderCell()} + )} + + + > + {({ item, rowId }) => ( + key={rowId} onDoubleClick={() => { + this.props.history.push("/identities/" + rowId + "/view") + }}> + {({ renderCell }) => ( + {renderCell(item)} + )} + + )} + +
) }