From a74c2eb1efb646cdbb3c6a73c0e16b69810c3a56 Mon Sep 17 00:00:00 2001 From: Leon van der Grient Date: Sat, 23 Sep 2023 11:34:47 +0200 Subject: [PATCH] feat: add admin user profile with avatar (#75) --- packages/content-platform/package.json | 2 +- packages/content-platform/server/bootstrap.ts | 5 + .../server/content-types/index.ts | 2 + .../server/content-types/profile.ts | 30 +++++ .../server/controllers/index.ts | 2 + .../server/controllers/profile.controller.ts | 10 ++ .../server/lifecycles/profile.lifecycle.ts | 48 ++++++++ .../content-platform/server/routes/index.ts | 12 ++ .../content-platform/server/services/index.ts | 2 + .../server/services/profile.service.ts | 37 ++++++ packages/studio/app/hooks/useSession.ts | 9 +- .../studio/app/providers/StrapiProvider.tsx | 3 +- packages/studio/app/routes/Profile.tsx | 112 +++++++++++------- packages/studio/app/types/adminUser.ts | 5 + packages/studio/app/ui/MainMenu/UserMenu.tsx | 3 +- packages/studio/app/utils/sdk.ts | 17 ++- 16 files changed, 251 insertions(+), 48 deletions(-) create mode 100644 packages/content-platform/server/content-types/profile.ts create mode 100644 packages/content-platform/server/controllers/profile.controller.ts create mode 100644 packages/content-platform/server/lifecycles/profile.lifecycle.ts create mode 100644 packages/content-platform/server/services/profile.service.ts diff --git a/packages/content-platform/package.json b/packages/content-platform/package.json index de57e5d..c414842 100644 --- a/packages/content-platform/package.json +++ b/packages/content-platform/package.json @@ -6,7 +6,7 @@ "strapi": { "displayName": "Curator", "name": "curator", - "description": "Curator is a full-stack CMS built on Strapi.", + "description": "Curator is a full-stack CMS built on Strapi and Next.js.", "kind": "plugin" }, "dependencies": { diff --git a/packages/content-platform/server/bootstrap.ts b/packages/content-platform/server/bootstrap.ts index 8984591..9569d14 100644 --- a/packages/content-platform/server/bootstrap.ts +++ b/packages/content-platform/server/bootstrap.ts @@ -2,6 +2,7 @@ import type { Strapi } from "@strapi/strapi"; import versioningLifecycle from "./lifecycles/versioning.lifecycle"; import auditLifecycle from "./lifecycles/audit.lifecycle"; +import profileLifecycle from "./lifecycles/profile.lifecycle"; export default async function ({ strapi }: { strapi: Strapi }) { /* @@ -12,4 +13,8 @@ export default async function ({ strapi }: { strapi: Strapi }) { * Set up versioning lifecycle hooks. */ versioningLifecycle(strapi); + /* + * Set up admin user profile lifecycle hooks. + */ + profileLifecycle(strapi); } diff --git a/packages/content-platform/server/content-types/index.ts b/packages/content-platform/server/content-types/index.ts index 9e9a725..83eed1b 100644 --- a/packages/content-platform/server/content-types/index.ts +++ b/packages/content-platform/server/content-types/index.ts @@ -1,9 +1,11 @@ import secretSchema from "./secret"; import auditSchema from "./audit"; import versionSchema from "./version"; +import profileSchema from "./profile"; export default { "curator-secret": { schema: secretSchema }, "curator-audit-log": { schema: auditSchema }, "curator-version": { schema: versionSchema }, + "curator-admin-user-profile": { schema: profileSchema }, }; diff --git a/packages/content-platform/server/content-types/profile.ts b/packages/content-platform/server/content-types/profile.ts new file mode 100644 index 0000000..b2b36d4 --- /dev/null +++ b/packages/content-platform/server/content-types/profile.ts @@ -0,0 +1,30 @@ +export default { + kind: "collectionType", + collectionName: "curator_admin_user_profile", + info: { + singularName: "curator-admin-user-profile", + pluralName: "curator-admin-user-profiles", + displayName: "Curator Admin User Profile", + description: "A profile for admin users.", + }, + options: { + draftAndPublish: false, + }, + pluginOptions: { + "content-type-builder": { + visible: false, + }, + }, + attributes: { + user: { + type: "relation", + relation: "oneToOne", + target: "admin::user", + }, + avatar: { + type: "media", + multiple: false, + allowedTypes: ["images"], + }, + }, +}; diff --git a/packages/content-platform/server/controllers/index.ts b/packages/content-platform/server/controllers/index.ts index e9c7d17..bff94cd 100644 --- a/packages/content-platform/server/controllers/index.ts +++ b/packages/content-platform/server/controllers/index.ts @@ -1,9 +1,11 @@ import dashboardController from "./dashboard.controller"; import secretsController from "./secrets.controller"; import versioningController from "./versioning.controller"; +import profileController from "./profile.controller"; export default { secretsController, dashboardController, versioningController, + profileController, }; diff --git a/packages/content-platform/server/controllers/profile.controller.ts b/packages/content-platform/server/controllers/profile.controller.ts new file mode 100644 index 0000000..f0be621 --- /dev/null +++ b/packages/content-platform/server/controllers/profile.controller.ts @@ -0,0 +1,10 @@ +import { Strapi } from "@strapi/strapi"; + +export default ({ strapi }: { strapi: Strapi }) => ({ + getMe() { + return strapi.plugin("curator").service("profileService").getMe(); + }, + updateMe() { + return strapi.plugin("curator").service("profileService").updateMe(); + }, +}); diff --git a/packages/content-platform/server/lifecycles/profile.lifecycle.ts b/packages/content-platform/server/lifecycles/profile.lifecycle.ts new file mode 100644 index 0000000..0594315 --- /dev/null +++ b/packages/content-platform/server/lifecycles/profile.lifecycle.ts @@ -0,0 +1,48 @@ +import type { Strapi } from "@strapi/strapi"; +import * as R from "ramda"; + +const ADMIN_USER_UID = "admin::user"; +const PROFILE_UID = "plugin::curator.curator-admin-user-profile"; + +export default async function profileLifecycle(strapi: Strapi) { + /* + * Make sure admin users always have a profile + */ + const admins = (await strapi.entityService.findMany(ADMIN_USER_UID)) as any[]; + + const existingProfiles = (await strapi.entityService.findMany(PROFILE_UID, { + filters: { + user: R.pluck("id", admins), + }, + populate: ["user"], + })) as any[]; + + const adminsWithoutProfile = R.without( + existingProfiles.map(R.path(["user", "id"])), + R.pluck("id", admins), + ); + + for await (const adminId of adminsWithoutProfile) { + await strapi.entityService.create(PROFILE_UID, { data: { user: adminId } }); + } + + strapi.db.lifecycles.subscribe({ + models: [ADMIN_USER_UID], + + async afterCreate({ result }: any) { + await strapi.entityService.create(PROFILE_UID, { + data: { user: result.id }, + }); + }, + + async afterDelete({ result }: any) { + const items = await strapi.entityService.findMany(PROFILE_UID, { + filters: { user: result.id }, + }); + + if (Array.isArray(items) && items[0]) { + await strapi.entityService.delete(PROFILE_UID, items[0].id); + } + }, + } as any); +} diff --git a/packages/content-platform/server/routes/index.ts b/packages/content-platform/server/routes/index.ts index 0afada7..20ff7fe 100644 --- a/packages/content-platform/server/routes/index.ts +++ b/packages/content-platform/server/routes/index.ts @@ -17,4 +17,16 @@ export default [ handler: "versioningController.list", config: {}, }, + { + method: "GET", + path: "/profiles/me", + handler: "profileController.getMe", + config: {}, + }, + { + method: "PATCH", + path: "/profiles/me", + handler: "profileController.updateMe", + config: {}, + }, ]; diff --git a/packages/content-platform/server/services/index.ts b/packages/content-platform/server/services/index.ts index 23b14cc..a7ad694 100644 --- a/packages/content-platform/server/services/index.ts +++ b/packages/content-platform/server/services/index.ts @@ -1,9 +1,11 @@ import secretsService from "./secrets.service"; import dashboardService from "./dashboard.service"; import versioningService from "./versioning.service"; +import profileService from "./profile.service"; export default { secretsService, dashboardService, versioningService, + profileService, }; diff --git a/packages/content-platform/server/services/profile.service.ts b/packages/content-platform/server/services/profile.service.ts new file mode 100644 index 0000000..427abc2 --- /dev/null +++ b/packages/content-platform/server/services/profile.service.ts @@ -0,0 +1,37 @@ +import { Strapi } from "@strapi/strapi"; + +const PROFILE_UID = "plugin::curator.curator-admin-user-profile"; + +export default ({ strapi }: { strapi: Strapi }) => ({ + async getMe() { + const ctx = strapi.requestContext.get(); + const user = ctx.state.user; + + const query = await strapi.entityService.findMany(PROFILE_UID, { + filters: { + user: user.id, + }, + populate: ["avatar"], + }); + + return Array.isArray(query) ? query[0] : {}; + }, + + async updateMe() { + const ctx = strapi.requestContext.get(); + const user = ctx.state.user; + + const query = await strapi.entityService.findMany(PROFILE_UID, { + filters: { + user: user.id, + }, + }); + + if (Array.isArray(query) && query[0]) { + return await strapi.entityService.update(PROFILE_UID, query[0].id, { + data: ctx.request.body, + populate: ["avatar"], + }); + } + }, +}); diff --git a/packages/studio/app/hooks/useSession.ts b/packages/studio/app/hooks/useSession.ts index 3872807..628af7c 100644 --- a/packages/studio/app/hooks/useSession.ts +++ b/packages/studio/app/hooks/useSession.ts @@ -2,17 +2,24 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { SessionUser } from "@/types/session"; +import { AdminProfile } from "@/types/adminUser"; interface SessionState { token: string | null; user: SessionUser | null; - setSession(session: { token?: string; user?: SessionUser }): void; + profile: AdminProfile | null; + setSession(session: { + token?: string; + user?: SessionUser; + profile?: AdminProfile; + }): void; clearSession(): void; } const initialState = { token: null, user: null, + profile: null, }; const useSession = create()( diff --git a/packages/studio/app/providers/StrapiProvider.tsx b/packages/studio/app/providers/StrapiProvider.tsx index 5752ed2..a7bbef5 100644 --- a/packages/studio/app/providers/StrapiProvider.tsx +++ b/packages/studio/app/providers/StrapiProvider.tsx @@ -58,6 +58,7 @@ export const StrapiProvider: React.FC<{ if (token) { const user = await sdk.getMe(); + const profile = await sdk.getExtendedProfile(); const permissions = await sdk.getPermissions(); if (permissions.some(R.whereEq({ action: "admin::roles.read" }))) { const roles = await sdk.getAdminRoles(); @@ -70,7 +71,7 @@ export const StrapiProvider: React.FC<{ setContentTypes(contentTypes); setComponents(components); setLocales(locales); - setSession({ user }); + setSession({ user, profile }); // Call login hooks for (const hook of hooks) { diff --git a/packages/studio/app/routes/Profile.tsx b/packages/studio/app/routes/Profile.tsx index 1edc1b9..55733c5 100644 --- a/packages/studio/app/routes/Profile.tsx +++ b/packages/studio/app/routes/Profile.tsx @@ -1,7 +1,8 @@ import React from "react"; import { Form, Formik } from "formik"; import { useTranslation } from "react-i18next"; -import { Button, Card, Input } from "antd"; +import { Avatar, Button, Card, Input } from "antd"; +import toColor from "string-to-color"; import useSession from "@/hooks/useSession"; import useStrapi from "@/hooks/useStrapi"; @@ -10,9 +11,11 @@ import Spinner from "@/ui/Spinner"; import Field from "@/ui/Field"; import FormField from "@/ui/FormField"; import LocaleSelect from "@/ui/LocaleSelect"; +import { MediaLibraryPopover } from "@/plugins/media-library"; +import Popover from "@/ui/Popover"; export default function Profile() { - const { user, setSession } = useSession(); + const { user, profile, setSession } = useSession(); const { t } = useTranslation(); const { sdk } = useStrapi(); @@ -34,7 +37,7 @@ export default function Profile() { } catch (e) {} }} > - {() => ( + {({ values }) => (

{t("common.profile")}

@@ -42,48 +45,71 @@ export default function Profile() { {t("common.save")}
-
- -
- - - - - -
-
- - - - - -
-
- - - - - -
-
- - - - - + +
+
+ ( + { + const profile = await sdk.updateExtendedProfile({ + avatar: item, + }); + setSession({ profile }); + close(); + }} + /> + )} + > + + {( + user.username?.[0] || + user.firstname?.[0] || + user.email[0] + ).toUpperCase()} + +
- - - -
- - - - - + + + + + +
+
+ + + + + +
+
+ + + + + +
- -
+ + + + + + + + + + +
+
)} diff --git a/packages/studio/app/types/adminUser.ts b/packages/studio/app/types/adminUser.ts index ae727f3..a3d1f09 100644 --- a/packages/studio/app/types/adminUser.ts +++ b/packages/studio/app/types/adminUser.ts @@ -1,4 +1,5 @@ import { UserRole } from "@/types/permission"; +import { MediaItem } from "@/types/media"; export interface AdminUser { blocked: boolean; @@ -13,3 +14,7 @@ export interface AdminUser { updatedAt: string; username: string; } + +export interface AdminProfile { + avatar: MediaItem | null; +} diff --git a/packages/studio/app/ui/MainMenu/UserMenu.tsx b/packages/studio/app/ui/MainMenu/UserMenu.tsx index 79d0546..da3b7c4 100644 --- a/packages/studio/app/ui/MainMenu/UserMenu.tsx +++ b/packages/studio/app/ui/MainMenu/UserMenu.tsx @@ -17,7 +17,7 @@ import useModifierKey from "@/hooks/useModifierKey"; const UserMenu: React.FC = () => { const { t } = useTranslation(); - const { user, clearSession } = useSession(); + const { user, profile, clearSession } = useSession(); const navigate = useNavigate(); const [modal, contextHolder] = Modal.useModal(); const modifierKey = useModifierKey(); @@ -110,6 +110,7 @@ const UserMenu: React.FC = () => { shape="square" className="w-8 h-8 cursor-pointer" style={{ backgroundColor: toColor(user.email) }} + src={profile?.avatar?.url} > {( diff --git a/packages/studio/app/utils/sdk.ts b/packages/studio/app/utils/sdk.ts index 7cc29d4..6765625 100644 --- a/packages/studio/app/utils/sdk.ts +++ b/packages/studio/app/utils/sdk.ts @@ -9,7 +9,7 @@ import { MediaFolder, MediaFolderStructure, MediaItem } from "@/types/media"; import { PaginatedResponse } from "@/types/response"; import { GetManyParams, GetMediaParams } from "@/types/request"; import { Permission, PermissionConfig, UserRole } from "@/types/permission"; -import { AdminUser } from "@/types/adminUser"; +import { AdminProfile, AdminUser } from "@/types/adminUser"; import { Version } from "@/types/versioning"; import { ApiToken } from "@/types/apiToken"; @@ -239,6 +239,21 @@ export class StrapiSdk { return data.data; } + public async getExtendedProfile() { + const { data } = await this.http.get("/curator/profiles/me"); + + return data; + } + + public async updateExtendedProfile(values: Partial) { + const { data } = await this.http.patch( + "/curator/profiles/me", + values, + ); + + return data; + } + public async getMediaItems(params?: GetMediaParams) { const { data } = await this.http.get>( "/upload/files",