diff --git a/README.md b/README.md index b98ee7dce..8eb1d4456 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,11 @@ You will then have to generate a JWT with the following claims: about status changes on a user account such as bans or suspensions. - `user.username` (**required**) - the username that should be used when being presented inside Coral to moderators and other users. +- `user.badges` (_optional_) - array of strings to be displayed as badges beside + username inside Coral, visible to other users and moderators. For example, to indicate + a user's subscription status. +- `user.role` (_optional_) - one of "COMMENTER", "STAFF", "MODERATOR", "ADMIN". Will create/update + Coral user with this role. An example of the claims for this token would be: diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserBadgesContainer.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserBadgesContainer.tsx new file mode 100644 index 000000000..58175463a --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/UserBadgesContainer.tsx @@ -0,0 +1,37 @@ +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import { UserBadgesContainer_user as UserData } from "coral-admin/__generated__/UserBadgesContainer_user.graphql"; +import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer"; + +import CLASSES from "coral-stream/classes"; +import { Tag } from "coral-ui/components"; + +interface Props { + user: UserData; +} + +const UserBadgesContainer: FunctionComponent = ({ user }) => { + if (!user.badges) { + return null; + } + return ( + <> + {user.badges.map(badge => ( + + {badge} + + ))} + + ); +}; + +const enhanced = withFragmentContainer({ + user: graphql` + fragment UserBadgesContainer_user on User { + badges + } + `, +})(UserBadgesContainer); + +export default enhanced; diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx index 46c70ee23..549c8e8f8 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx @@ -11,6 +11,7 @@ import { Button, Flex, Icon, Typography } from "coral-ui/components"; import RecentHistoryContainer from "./RecentHistoryContainer"; import Tabs from "./Tabs"; +import UserBadgesContainer from "./UserBadgesContainer"; import UserStatusDetailsContainer from "./UserStatusDetailsContainer"; import styles from "./UserHistoryDrawerContainer.css"; @@ -38,8 +39,11 @@ const UserHistoryDrawerContainer: FunctionComponent = ({ - + {user.username} +
+ +
@@ -130,6 +134,7 @@ const UserHistoryDrawerContainer: FunctionComponent = ({ const enhanced = withFragmentContainer({ user: graphql` fragment UserHistoryDrawerContainer_user on User { + ...UserBadgesContainer_user ...UserStatusChangeContainer_user ...UserStatusDetailsContainer_user ...RecentHistoryContainer_user diff --git a/src/core/client/stream/classes.ts b/src/core/client/stream/classes.ts index bf9301d45..48921dad5 100644 --- a/src/core/client/stream/classes.ts +++ b/src/core/client/stream/classes.ts @@ -95,6 +95,11 @@ const CLASSES = { */ userTag: "coral coral-comment-userTag", + /** + * userBadge can be used to target a badge associated with a User. + */ + userBadge: "coral coral-comment-userBadge", + /** * commentTag can be used to target a tag associated with a Comment. */ diff --git a/src/core/client/stream/tabs/Comments/Comment/AuthorBadgesContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/AuthorBadgesContainer.tsx new file mode 100644 index 000000000..e5f09e5e0 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/AuthorBadgesContainer.tsx @@ -0,0 +1,39 @@ +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer"; +import { AuthorBadgesContainer_comment as CommentData } from "coral-stream/__generated__/AuthorBadgesContainer_comment.graphql"; + +import CLASSES from "coral-stream/classes"; +import { Tag } from "coral-ui/components"; + +interface Props { + comment: CommentData; +} + +const AuthorBadgesContainer: FunctionComponent = ({ comment }) => { + if (!comment.author || !comment.author.badges) { + return null; + } + return ( + <> + {comment.author.badges.map(badge => ( + + {badge} + + ))} + + ); +}; + +const enhanced = withFragmentContainer({ + comment: graphql` + fragment AuthorBadgesContainer_comment on Comment { + author { + badges + } + } + `, +})(AuthorBadgesContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx index e43262555..9e64776d4 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx @@ -27,6 +27,7 @@ function createDefaultProps(add: DeepPartial = {}): Props { author: { id: "author-id", username: "Marvin", + badges: [], }, parent: null, body: "Woof", diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index e84ebc7c3..2be0fc8f8 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -21,6 +21,7 @@ import { import { Button, Flex, HorizontalGutter, Tag } from "coral-ui/components"; import { isPublished } from "../helpers"; +import UserBadgesContainer from "./AuthorBadgesContainer"; import ButtonsBar from "./ButtonsBar"; import EditCommentFormContainer from "./EditCommentForm"; import IndentedComment from "./IndentedComment"; @@ -242,6 +243,7 @@ export class CommentContainer extends Component { user={comment.author} /> + ) } @@ -342,6 +344,7 @@ const enhanced = withSetCommentIDMutation( ignoredUsers { id } + badges role ...UsernameWithPopoverContainer_viewer ...ReactionButtonContainer_viewer @@ -389,6 +392,7 @@ const enhanced = withSetCommentIDMutation( ...ReportButtonContainer_comment ...CaretContainer_comment ...RejectedTombstoneContainer_comment + ...AuthorBadgesContainer_comment ...UserTagsContainer_comment } `, diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts index b4bbdf60b..7ceed6b4c 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts @@ -179,6 +179,7 @@ function commit( id: viewer.id, username: viewer.username, createdAt: viewer.createdAt, + badges: viewer.badges, ignoreable: false, }, body: input.body, diff --git a/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap index aa318f49e..61c3c15a4 100644 --- a/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap @@ -20,6 +20,7 @@ exports[`hide reply button 1`] = ` comment={ Object { "author": Object { + "badges": Array [], "id": "author-id", "username": "Marvin", }, @@ -62,6 +63,7 @@ exports[`hide reply button 1`] = ` comment={ Object { "author": Object { + "badges": Array [], "id": "author-id", "username": "Marvin", }, @@ -101,6 +103,7 @@ exports[`hide reply button 1`] = ` + + + + + + + = props => { [classes.colorPrimary]: color === "primary", [classes.colorError]: color === "error", [classes.colorGrey]: color === "grey", + [classes.colorDarkest]: color === "dark", }); return ( diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts index 79910eedb..2b118367f 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts @@ -6,7 +6,14 @@ import { validate } from "coral-server/app/request/body"; describe("isSSOToken", () => { it("understands valid sso tokens", () => { - const token = { user: { id: "id", email: "email", username: "username" } }; + const token = { + user: { + id: "id", + email: "email", + username: "username", + role: "COMMENTER", + }, + }; expect(isSSOToken(token)).toBeTruthy(); }); @@ -20,6 +27,15 @@ describe("isSSOToken", () => { expect( isSSOToken({ user: { email: "email", username: "username" } } as object) ).toBeFalsy(); + expect( + isSSOToken({ + user: { + email: "email", + username: "username", + role: "SUPERADMIN", + }, + } as object) + ).toBeFalsy(); expect(isSSOToken({})).toBeFalsy(); }); }); diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts index 2e0792870..d77d6a4df 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -37,6 +37,8 @@ export interface SSOUserProfile { id: string; email: string; username: string; + badges?: string[]; + role?: GQLUSER_ROLE; } export interface SSOToken { @@ -51,13 +53,17 @@ export function isSSOToken(token: SSOToken | object): token is SSOToken { return isNil(error); } -export const SSOUserProfileSchema = Joi.object().keys({ - id: Joi.string().required(), - email: Joi.string() - .lowercase() - .required(), - username: Joi.string().required(), -}); +export const SSOUserProfileSchema = Joi.object() + .keys({ + id: Joi.string().required(), + email: Joi.string() + .lowercase() + .required(), + username: Joi.string().required(), + badges: Joi.array().items(Joi.string()), + role: Joi.string().only(Object.values(GQLUSER_ROLE)), + }) + .optionalKeys(["badges", "role"]); export const SSOTokenSchema = Joi.object() .keys({ @@ -88,7 +94,7 @@ export async function findOrCreateSSOUser( const { jti, exp, - user: { id, email, username }, + user: { id, email, username, badges, role }, iat, } = decodedToken; @@ -127,7 +133,8 @@ export async function findOrCreateSSOUser( { id, username, - role: GQLUSER_ROLE.COMMENTER, + role: role || GQLUSER_ROLE.COMMENTER, + badges, email, profiles: [profile], }, @@ -144,7 +151,7 @@ export async function findOrCreateSSOUser( mongo, tenant.id, user.id, - { email, username }, + { email, username, badges, role: role || user.role }, lastIssuedAt ); } diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 34f5f8281..14e7b1fd2 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -1525,6 +1525,10 @@ type User { """ avatar: String + """ + badges are user display badges + """ + badges: [String!] """ email is the current email address for the User. """ diff --git a/src/core/server/models/user/helpers.ts b/src/core/server/models/user/helpers.ts index 17a8ba712..b4fec4940 100644 --- a/src/core/server/models/user/helpers.ts +++ b/src/core/server/models/user/helpers.ts @@ -1,5 +1,8 @@ +import { isEqual } from "lodash"; + import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types"; +import { SSOUserProfile } from "coral-server/app/middleware/passport/strategies/verifiers/sso"; import { STAFF_ROLES } from "./constants"; import { LocalProfile, SSOProfile, User } from "./user"; @@ -22,10 +25,15 @@ export function getSSOProfile(user: Pick) { } export function needsSSOUpdate( - token: Pick, - user: Pick + token: SSOUserProfile, + user: Pick ) { - return user.email !== token.email || user.username !== token.username; + return ( + user.email !== token.email || + user.username !== token.username || + (token.role && user.role !== token.role) || + !isEqual(user.badges, token.badges) + ); } /** diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 6ba370ccf..43b67abad 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -292,6 +292,12 @@ export interface User extends TenantResource { */ email?: string; + /** + * + * badges are user display badges + */ + badges?: string[]; + /** * emailVerificationID is used to store state regarding the verification state * of an email address to prevent replay attacks. @@ -668,6 +674,8 @@ export async function updateUserPassword( export interface UpdateUserInput { email?: string; username?: string; + badges?: string[]; + role?: GQLUSER_ROLE; } export async function updateUserFromSSO( @@ -677,7 +685,7 @@ export async function updateUserFromSSO( update: UpdateUserInput, lastIssuedAt: Date ) { - // Update the user with the new password. + // Update the user with the new properties. const result = await collection(mongo).findOneAndUpdate( { tenantID,