[CORL-476] Add badges to user from SSO token (#2470)

* fix: bug in lookup not checking object properly before accessing

* fix: Recursive types not handling optional arrays

* add user badges component

* create user badges from sso token

* update badges type

* revert src/core/client/embed/index.html

* remove duplicated line

* add user badges component

* create user badges from sso token

* revert src/core/client/embed/index.html

* remove duplicated line

* fix types

* fix tests and snaps

* add user badges to user drawer

* update snaps

* update readme

* [CORL-476] add user role from SSO token (#2475)

* add role from token

* use joi to validate role values

Co-Authored-By: Wyatt Johnson <wyattjoh@gmail.com>

* simplify sso.role validation

* add test for invalid role in sso token
This commit is contained in:
Tessa Thornton
2019-08-20 13:15:59 -04:00
committed by GitHub
parent add5224338
commit 7809cd3d68
17 changed files with 358 additions and 18 deletions
+5
View File
@@ -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:
@@ -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<Props> = ({ user }) => {
if (!user.badges) {
return null;
}
return (
<>
{user.badges.map(badge => (
<Tag key={badge} color="dark" className={CLASSES.comment.userBadge}>
{badge}
</Tag>
))}
</>
);
};
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserBadgesContainer_user on User {
badges
}
`,
})(UserBadgesContainer);
export default enhanced;
@@ -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<Props> = ({
<Button className={styles.close} onClick={onClose}>
<Icon size="md">close</Icon>
</Button>
<Flex className={styles.username}>
<Flex className={styles.username} itemGutter>
<span>{user.username}</span>
<div>
<UserBadgesContainer user={user} />
</div>
</Flex>
<div className={styles.userStatus}>
<Flex alignItems="center" itemGutter="half">
@@ -130,6 +134,7 @@ const UserHistoryDrawerContainer: FunctionComponent<Props> = ({
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserHistoryDrawerContainer_user on User {
...UserBadgesContainer_user
...UserStatusChangeContainer_user
...UserStatusDetailsContainer_user
...RecentHistoryContainer_user
+5
View File
@@ -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.
*/
@@ -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<Props> = ({ comment }) => {
if (!comment.author || !comment.author.badges) {
return null;
}
return (
<>
{comment.author.badges.map(badge => (
<Tag key={badge} color="dark" className={CLASSES.comment.userBadge}>
{badge}
</Tag>
))}
</>
);
};
const enhanced = withFragmentContainer<Props>({
comment: graphql`
fragment AuthorBadgesContainer_comment on Comment {
author {
badges
}
}
`,
})(AuthorBadgesContainer);
export default enhanced;
@@ -27,6 +27,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
author: {
id: "author-id",
username: "Marvin",
badges: [],
},
parent: null,
body: "Woof",
@@ -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<Props, State> {
user={comment.author}
/>
<UserTagsContainer comment={comment} />
<UserBadgesContainer comment={comment} />
</>
)
}
@@ -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
}
`,
@@ -179,6 +179,7 @@ function commit(
id: viewer.id,
username: viewer.username,
createdAt: viewer.createdAt,
badges: viewer.badges,
ignoreable: false,
},
body: input.body,
@@ -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`] = `
<Relay(UsernameWithPopoverContainer)
user={
Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
}
@@ -111,6 +114,30 @@ exports[`hide reply button 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
}
}
/>
<Relay(AuthorBadgesContainer)
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -156,6 +183,7 @@ exports[`renders body only 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": null,
},
@@ -204,6 +232,7 @@ exports[`renders body only 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": null,
},
@@ -243,6 +272,7 @@ exports[`renders body only 1`] = `
<Relay(UsernameWithPopoverContainer)
user={
Object {
"badges": Array [],
"id": "author-id",
"username": null,
}
@@ -253,6 +283,30 @@ exports[`renders body only 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": null,
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
}
}
/>
<Relay(AuthorBadgesContainer)
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": null,
},
@@ -298,6 +352,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -346,6 +401,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -385,6 +441,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
<Relay(UsernameWithPopoverContainer)
user={
Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
}
@@ -395,6 +452,30 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
}
}
/>
<Relay(AuthorBadgesContainer)
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -440,6 +521,7 @@ exports[`renders disabled reply when story is closed 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -488,6 +570,7 @@ exports[`renders disabled reply when story is closed 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -527,6 +610,7 @@ exports[`renders disabled reply when story is closed 1`] = `
<Relay(UsernameWithPopoverContainer)
user={
Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
}
@@ -537,6 +621,30 @@ exports[`renders disabled reply when story is closed 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
}
}
/>
<Relay(AuthorBadgesContainer)
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -582,6 +690,7 @@ exports[`renders in reply to 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -634,6 +743,7 @@ exports[`renders in reply to 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -677,6 +787,7 @@ exports[`renders in reply to 1`] = `
<Relay(UsernameWithPopoverContainer)
user={
Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
}
@@ -687,6 +798,34 @@ exports[`renders in reply to 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": Object {
"author": Object {
"username": "ParentAuthor",
},
},
"pending": false,
"status": "NONE",
"tags": Array [],
}
}
/>
<Relay(AuthorBadgesContainer)
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -736,6 +875,7 @@ exports[`renders username and body 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -784,6 +924,7 @@ exports[`renders username and body 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -823,6 +964,7 @@ exports[`renders username and body 1`] = `
<Relay(UsernameWithPopoverContainer)
user={
Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
}
@@ -833,6 +975,30 @@ exports[`renders username and body 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
}
}
/>
<Relay(AuthorBadgesContainer)
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -878,6 +1044,7 @@ exports[`shows conversation link 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -926,6 +1093,7 @@ exports[`shows conversation link 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -970,6 +1138,7 @@ exports[`shows conversation link 1`] = `
<Relay(UsernameWithPopoverContainer)
user={
Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
}
@@ -980,6 +1149,30 @@ exports[`shows conversation link 1`] = `
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
}
}
/>
<Relay(AuthorBadgesContainer)
comment={
Object {
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
@@ -78,6 +78,7 @@ graphql`
fragment CreateCommentMutation_viewer on User {
role
createdAt
badges
}
`;
// tslint:disable-next-line:no-unused-expression
@@ -149,6 +150,7 @@ function commit(
id: viewer.id,
username: viewer.username,
createdAt: viewer.createdAt,
badges: viewer.badges,
ignoreable: false,
},
revision: {
+5 -1
View File
@@ -2,7 +2,7 @@
composes: tagText from "coral-ui/shared/typography.css";
color: var(--palette-text-light);
line-height: 1;
padding: 2px 4px;
padding: var(--spacing-1);
white-space: nowrap;
border-radius: 1px;
display: inline-block;
@@ -20,6 +20,10 @@
background-color: var(--palette-error-dark);
}
.colorDarkest {
background-color: var(--palette-primary-darkest);
}
.variantPill {
border-radius: 20px;
padding: 2px 6px;
+2 -1
View File
@@ -12,7 +12,7 @@ interface Props {
*/
classes: typeof styles;
children: React.ReactNode;
color?: "grey" | "primary" | "error";
color?: "grey" | "primary" | "error" | "dark";
variant?: "regular" | "pill";
}
@@ -25,6 +25,7 @@ const Tag: FunctionComponent<Props> = props => {
[classes.colorPrimary]: color === "primary",
[classes.colorError]: color === "error",
[classes.colorGrey]: color === "grey",
[classes.colorDarkest]: color === "dark",
});
return (
@@ -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();
});
});
@@ -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
);
}
@@ -1525,6 +1525,10 @@ type User {
"""
avatar: String
"""
badges are user display badges
"""
badges: [String!]
"""
email is the current email address for the User.
"""
+11 -3
View File
@@ -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<User, "profiles">) {
}
export function needsSSOUpdate(
token: Pick<User, "email" | "username">,
user: Pick<User, "email" | "username">
token: SSOUserProfile,
user: Pick<User, "email" | "username" | "badges" | "role">
) {
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)
);
}
/**
+9 -1
View File
@@ -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,