chore: Migrate authentication to new tables (#1929)
This work provides a foundation for a more pluggable authentication system such as the one outlined in #1317. closes #1317
This commit is contained in:
parent
ab7b16bbb9
commit
ed2a42ac27
|
@ -1,7 +1,6 @@
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
node_modules/*
|
node_modules/*
|
||||||
server/scripts
|
|
||||||
.env
|
.env
|
||||||
.log
|
.log
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|
|
@ -6,8 +6,6 @@ class Team extends BaseModel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
slackConnected: boolean;
|
|
||||||
googleConnected: boolean;
|
|
||||||
sharing: boolean;
|
sharing: boolean;
|
||||||
documentEmbeds: boolean;
|
documentEmbeds: boolean;
|
||||||
guestSignin: boolean;
|
guestSignin: boolean;
|
||||||
|
@ -17,11 +15,7 @@ class Team extends BaseModel {
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get signinMethods(): string {
|
get signinMethods(): string {
|
||||||
if (this.slackConnected && this.googleConnected) {
|
return "SSO";
|
||||||
return "Slack or Google";
|
|
||||||
}
|
|
||||||
if (this.slackConnected) return "Slack";
|
|
||||||
return "Google";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -97,8 +97,7 @@ class Notifications extends React.Component<Props> {
|
||||||
|
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Manage when and where you receive email notifications from Outline.
|
Manage when and where you receive email notifications from Outline.
|
||||||
Your email address can be updated in your{" "}
|
Your email address can be updated in your SSO provider.
|
||||||
{team.slackConnected ? "Slack" : "Google"} account.
|
|
||||||
</HelpText>
|
</HelpText>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
// @flow
|
// @flow
|
||||||
declare var process: {
|
declare var process: {
|
||||||
|
exit: (code?: number) => void,
|
||||||
env: {
|
env: {
|
||||||
[string]: string,
|
[string]: string,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"verbose": true,
|
"verbose": false,
|
||||||
"rootDir": "..",
|
"rootDir": "..",
|
||||||
"roots": [
|
"roots": [
|
||||||
"<rootDir>/server",
|
"<rootDir>/server",
|
||||||
|
|
|
@ -37,10 +37,14 @@ services.push({
|
||||||
function filterServices(team) {
|
function filterServices(team) {
|
||||||
let output = services;
|
let output = services;
|
||||||
|
|
||||||
if (team && !team.googleId) {
|
const providerNames = team
|
||||||
|
? team.authenticationProviders.map((provider) => provider.name)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (team && !providerNames.includes("google")) {
|
||||||
output = reject(output, (service) => service.id === "google");
|
output = reject(output, (service) => service.id === "google");
|
||||||
}
|
}
|
||||||
if (team && !team.slackId) {
|
if (team && !providerNames.includes("slack")) {
|
||||||
output = reject(output, (service) => service.id === "slack");
|
output = reject(output, (service) => service.id === "slack");
|
||||||
}
|
}
|
||||||
if (!team || !team.guestSignin) {
|
if (!team || !team.guestSignin) {
|
||||||
|
@ -55,7 +59,7 @@ router.post("auth.config", async (ctx) => {
|
||||||
// brand for the knowledge base and it's guest signin option is used for the
|
// brand for the knowledge base and it's guest signin option is used for the
|
||||||
// root login page.
|
// root login page.
|
||||||
if (process.env.DEPLOYMENT !== "hosted") {
|
if (process.env.DEPLOYMENT !== "hosted") {
|
||||||
const teams = await Team.findAll();
|
const teams = await Team.scope("withAuthenticationProviders").findAll();
|
||||||
|
|
||||||
if (teams.length === 1) {
|
if (teams.length === 1) {
|
||||||
const team = teams[0];
|
const team = teams[0];
|
||||||
|
@ -70,7 +74,7 @@ router.post("auth.config", async (ctx) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCustomDomain(ctx.request.hostname)) {
|
if (isCustomDomain(ctx.request.hostname)) {
|
||||||
const team = await Team.findOne({
|
const team = await Team.scope("withAuthenticationProviders").findOne({
|
||||||
where: { domain: ctx.request.hostname },
|
where: { domain: ctx.request.hostname },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -95,7 +99,7 @@ router.post("auth.config", async (ctx) => {
|
||||||
) {
|
) {
|
||||||
const domain = parseDomain(ctx.request.hostname);
|
const domain = parseDomain(ctx.request.hostname);
|
||||||
const subdomain = domain ? domain.subdomain : undefined;
|
const subdomain = domain ? domain.subdomain : undefined;
|
||||||
const team = await Team.findOne({
|
const team = await Team.scope("withAuthenticationProviders").findOne({
|
||||||
where: { subdomain },
|
where: { subdomain },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@ import Router from "koa-router";
|
||||||
import { escapeRegExp } from "lodash";
|
import { escapeRegExp } from "lodash";
|
||||||
import { AuthenticationError, InvalidRequestError } from "../errors";
|
import { AuthenticationError, InvalidRequestError } from "../errors";
|
||||||
import {
|
import {
|
||||||
|
UserAuthentication,
|
||||||
|
AuthenticationProvider,
|
||||||
Authentication,
|
Authentication,
|
||||||
Document,
|
Document,
|
||||||
User,
|
User,
|
||||||
|
@ -25,7 +27,14 @@ router.post("hooks.unfurl", async (ctx) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findOne({
|
const user = await User.findOne({
|
||||||
where: { service: "slack", serviceId: event.user },
|
include: [
|
||||||
|
{
|
||||||
|
where: { providerId: event.user },
|
||||||
|
model: UserAuthentication,
|
||||||
|
as: "authentications",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
|
@ -70,11 +79,21 @@ router.post("hooks.interactive", async (ctx) => {
|
||||||
throw new AuthenticationError("Invalid verification token");
|
throw new AuthenticationError("Invalid verification token");
|
||||||
}
|
}
|
||||||
|
|
||||||
const team = await Team.findOne({
|
const authProvider = await AuthenticationProvider.findOne({
|
||||||
where: { slackId: data.team.id },
|
where: {
|
||||||
|
name: "slack",
|
||||||
|
providerId: data.team.id,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Team,
|
||||||
|
as: "team",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!team) {
|
if (!authProvider) {
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
text:
|
text:
|
||||||
"Sorry, we couldn’t find an integration for your team. Head to your Outline settings to set one up.",
|
"Sorry, we couldn’t find an integration for your team. Head to your Outline settings to set one up.",
|
||||||
|
@ -84,6 +103,8 @@ router.post("hooks.interactive", async (ctx) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { team } = authProvider;
|
||||||
|
|
||||||
// we find the document based on the users teamId to ensure access
|
// we find the document based on the users teamId to ensure access
|
||||||
const document = await Document.findOne({
|
const document = await Document.findOne({
|
||||||
where: {
|
where: {
|
||||||
|
@ -131,20 +152,41 @@ router.post("hooks.slack", async (ctx) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let user;
|
let user, team;
|
||||||
|
|
||||||
// attempt to find the corresponding team for this request based on the team_id
|
// attempt to find the corresponding team for this request based on the team_id
|
||||||
let team = await Team.findOne({
|
team = await Team.findOne({
|
||||||
where: { slackId: team_id },
|
include: [
|
||||||
});
|
{
|
||||||
if (team) {
|
where: {
|
||||||
user = await User.findOne({
|
name: "slack",
|
||||||
where: {
|
providerId: team_id,
|
||||||
teamId: team.id,
|
},
|
||||||
service: "slack",
|
as: "authenticationProviders",
|
||||||
serviceId: user_id,
|
model: AuthenticationProvider,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (team) {
|
||||||
|
const authentication = await UserAuthentication.findOne({
|
||||||
|
where: {
|
||||||
|
providerId: user_id,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
where: { teamId: team.id },
|
||||||
|
model: User,
|
||||||
|
as: "user",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (authentication) {
|
||||||
|
user = authentication.user;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// If we couldn't find a team it's still possible that the request is from
|
// If we couldn't find a team it's still possible that the request is from
|
||||||
// a team that authenticated with a different service, but connected Slack
|
// a team that authenticated with a different service, but connected Slack
|
||||||
|
|
|
@ -33,7 +33,7 @@ describe("#hooks.unfurl", () => {
|
||||||
event: {
|
event: {
|
||||||
type: "link_shared",
|
type: "link_shared",
|
||||||
channel: "Cxxxxxx",
|
channel: "Cxxxxxx",
|
||||||
user: user.serviceId,
|
user: user.authentications[0].providerId,
|
||||||
message_ts: "123456789.9875",
|
message_ts: "123456789.9875",
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
|
@ -56,8 +56,8 @@ describe("#hooks.slack", () => {
|
||||||
const res = await server.post("/api/hooks.slack", {
|
const res = await server.post("/api/hooks.slack", {
|
||||||
body: {
|
body: {
|
||||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||||
user_id: user.serviceId,
|
user_id: user.authentications[0].providerId,
|
||||||
team_id: team.slackId,
|
team_id: team.authenticationProviders[0].providerId,
|
||||||
text: "dsfkndfskndsfkn",
|
text: "dsfkndfskndsfkn",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -76,8 +76,8 @@ describe("#hooks.slack", () => {
|
||||||
const res = await server.post("/api/hooks.slack", {
|
const res = await server.post("/api/hooks.slack", {
|
||||||
body: {
|
body: {
|
||||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||||
user_id: user.serviceId,
|
user_id: user.authentications[0].providerId,
|
||||||
team_id: team.slackId,
|
team_id: team.authenticationProviders[0].providerId,
|
||||||
text: "contains",
|
text: "contains",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -98,8 +98,8 @@ describe("#hooks.slack", () => {
|
||||||
const res = await server.post("/api/hooks.slack", {
|
const res = await server.post("/api/hooks.slack", {
|
||||||
body: {
|
body: {
|
||||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||||
user_id: user.serviceId,
|
user_id: user.authentications[0].providerId,
|
||||||
team_id: team.slackId,
|
team_id: team.authenticationProviders[0].providerId,
|
||||||
text: "*contains",
|
text: "*contains",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -118,8 +118,8 @@ describe("#hooks.slack", () => {
|
||||||
const res = await server.post("/api/hooks.slack", {
|
const res = await server.post("/api/hooks.slack", {
|
||||||
body: {
|
body: {
|
||||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||||
user_id: user.serviceId,
|
user_id: user.authentications[0].providerId,
|
||||||
team_id: team.slackId,
|
team_id: team.authenticationProviders[0].providerId,
|
||||||
text: "contains",
|
text: "contains",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -137,8 +137,8 @@ describe("#hooks.slack", () => {
|
||||||
await server.post("/api/hooks.slack", {
|
await server.post("/api/hooks.slack", {
|
||||||
body: {
|
body: {
|
||||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||||
user_id: user.serviceId,
|
user_id: user.authentications[0].providerId,
|
||||||
team_id: team.slackId,
|
team_id: team.authenticationProviders[0].providerId,
|
||||||
text: "contains",
|
text: "contains",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -161,8 +161,8 @@ describe("#hooks.slack", () => {
|
||||||
const res = await server.post("/api/hooks.slack", {
|
const res = await server.post("/api/hooks.slack", {
|
||||||
body: {
|
body: {
|
||||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||||
user_id: user.serviceId,
|
user_id: user.authentications[0].providerId,
|
||||||
team_id: team.slackId,
|
team_id: team.authenticationProviders[0].providerId,
|
||||||
text: "help",
|
text: "help",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -176,8 +176,8 @@ describe("#hooks.slack", () => {
|
||||||
const res = await server.post("/api/hooks.slack", {
|
const res = await server.post("/api/hooks.slack", {
|
||||||
body: {
|
body: {
|
||||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||||
user_id: user.serviceId,
|
user_id: user.authentications[0].providerId,
|
||||||
team_id: team.slackId,
|
team_id: team.authenticationProviders[0].providerId,
|
||||||
text: "",
|
text: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -206,7 +206,7 @@ describe("#hooks.slack", () => {
|
||||||
body: {
|
body: {
|
||||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||||
user_id: "unknown-slack-user-id",
|
user_id: "unknown-slack-user-id",
|
||||||
team_id: team.slackId,
|
team_id: team.authenticationProviders[0].providerId,
|
||||||
text: "contains",
|
text: "contains",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -260,8 +260,8 @@ describe("#hooks.slack", () => {
|
||||||
const res = await server.post("/api/hooks.slack", {
|
const res = await server.post("/api/hooks.slack", {
|
||||||
body: {
|
body: {
|
||||||
token: "wrong-verification-token",
|
token: "wrong-verification-token",
|
||||||
team_id: team.slackId,
|
user_id: user.authentications[0].providerId,
|
||||||
user_id: user.serviceId,
|
team_id: team.authenticationProviders[0].providerId,
|
||||||
text: "Welcome",
|
text: "Welcome",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -280,8 +280,8 @@ describe("#hooks.interactive", () => {
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||||
user: { id: user.serviceId },
|
user: { id: user.authentications[0].providerId },
|
||||||
team: { id: team.slackId },
|
team: { id: team.authenticationProviders[0].providerId },
|
||||||
callback_id: document.id,
|
callback_id: document.id,
|
||||||
});
|
});
|
||||||
const res = await server.post("/api/hooks.interactive", {
|
const res = await server.post("/api/hooks.interactive", {
|
||||||
|
@ -305,7 +305,7 @@ describe("#hooks.interactive", () => {
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||||
user: { id: "unknown-slack-user-id" },
|
user: { id: "unknown-slack-user-id" },
|
||||||
team: { id: team.slackId },
|
team: { id: team.authenticationProviders[0].providerId },
|
||||||
callback_id: document.id,
|
callback_id: document.id,
|
||||||
});
|
});
|
||||||
const res = await server.post("/api/hooks.interactive", {
|
const res = await server.post("/api/hooks.interactive", {
|
||||||
|
@ -322,7 +322,7 @@ describe("#hooks.interactive", () => {
|
||||||
const { user } = await seed();
|
const { user } = await seed();
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
token: "wrong-verification-token",
|
token: "wrong-verification-token",
|
||||||
user: { id: user.serviceId, name: user.name },
|
user: { id: user.authentications[0].providerId, name: user.name },
|
||||||
callback_id: "doesnt-matter",
|
callback_id: "doesnt-matter",
|
||||||
});
|
});
|
||||||
const res = await server.post("/api/hooks.interactive", {
|
const res = await server.post("/api/hooks.interactive", {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
import subMinutes from "date-fns/sub_minutes";
|
import subMinutes from "date-fns/sub_minutes";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
|
import { find } from "lodash";
|
||||||
import { AuthorizationError } from "../errors";
|
import { AuthorizationError } from "../errors";
|
||||||
import mailer from "../mailer";
|
import mailer from "../mailer";
|
||||||
import auth from "../middlewares/authentication";
|
import auth from "../middlewares/authentication";
|
||||||
|
@ -19,23 +20,27 @@ router.post("email", async (ctx) => {
|
||||||
|
|
||||||
ctx.assertEmail(email, "email is required");
|
ctx.assertEmail(email, "email is required");
|
||||||
|
|
||||||
const user = await User.findOne({
|
const user = await User.scope("withAuthentications").findOne({
|
||||||
where: { email: email.toLowerCase() },
|
where: { email: email.toLowerCase() },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const team = await Team.findByPk(user.teamId);
|
const team = await Team.scope("withAuthenticationProviders").findByPk(
|
||||||
|
user.teamId
|
||||||
|
);
|
||||||
if (!team) {
|
if (!team) {
|
||||||
ctx.redirect(`/?notice=auth-error`);
|
ctx.redirect(`/?notice=auth-error`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user matches an email address associated with an SSO
|
// If the user matches an email address associated with an SSO
|
||||||
// signin then just forward them directly to that service's
|
// provider then just forward them directly to that sign-in page
|
||||||
// login page
|
if (user.authentications.length) {
|
||||||
if (user.service && user.service !== "email") {
|
const authProvider = find(team.authenticationProviders, {
|
||||||
|
id: user.authentications[0].authenticationProviderId,
|
||||||
|
});
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
redirect: `${team.url}/auth/${user.service}`,
|
redirect: `${team.url}/auth/${authProvider.name}`,
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -87,11 +92,7 @@ router.get("email.callback", auth({ required: false }), async (ctx) => {
|
||||||
throw new AuthorizationError();
|
throw new AuthorizationError();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.service) {
|
await user.update({ lastActiveAt: new Date() });
|
||||||
user.service = "email";
|
|
||||||
user.lastActiveAt = new Date();
|
|
||||||
await user.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
// set cookies on response and redirect to team subdomain
|
// set cookies on response and redirect to team subdomain
|
||||||
ctx.signIn(user, team, "email", false);
|
ctx.signIn(user, team, "email", false);
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
// @flow
|
// @flow
|
||||||
import crypto from "crypto";
|
import * as Sentry from "@sentry/node";
|
||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import Sequelize from "sequelize";
|
import Sequelize from "sequelize";
|
||||||
|
import teamCreator from "../commands/teamCreator";
|
||||||
|
import userCreator from "../commands/userCreator";
|
||||||
import auth from "../middlewares/authentication";
|
import auth from "../middlewares/authentication";
|
||||||
import { User, Team } from "../models";
|
import { User } from "../models";
|
||||||
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
const client = new OAuth2Client(
|
const client = new OAuth2Client(
|
||||||
|
@ -55,90 +55,60 @@ router.get("google.callback", auth({ required: false }), async (ctx) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const googleId = profile.data.hd;
|
const domain = profile.data.hd;
|
||||||
const hostname = profile.data.hd.split(".")[0];
|
const subdomain = profile.data.hd.split(".")[0];
|
||||||
const teamName = capitalize(hostname);
|
const teamName = capitalize(subdomain);
|
||||||
|
|
||||||
// attempt to get logo from Clearbit API. If one doesn't exist then
|
let result;
|
||||||
// fall back to using tiley to generate a placeholder logo
|
|
||||||
const hash = crypto.createHash("sha256");
|
|
||||||
hash.update(googleId);
|
|
||||||
const hashedGoogleId = hash.digest("hex");
|
|
||||||
const cbUrl = `https://logo.clearbit.com/${profile.data.hd}`;
|
|
||||||
const tileyUrl = `https://tiley.herokuapp.com/avatar/${hashedGoogleId}/${teamName[0]}.png`;
|
|
||||||
const cbResponse = await fetch(cbUrl);
|
|
||||||
const avatarUrl = cbResponse.status === 200 ? cbUrl : tileyUrl;
|
|
||||||
|
|
||||||
let team, isFirstUser;
|
|
||||||
try {
|
try {
|
||||||
[team, isFirstUser] = await Team.findOrCreate({
|
result = await teamCreator({
|
||||||
where: {
|
name: teamName,
|
||||||
googleId,
|
domain,
|
||||||
},
|
subdomain,
|
||||||
defaults: {
|
authenticationProvider: {
|
||||||
name: teamName,
|
name: "google",
|
||||||
avatarUrl,
|
providerId: domain,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Sequelize.UniqueConstraintError) {
|
if (err instanceof Sequelize.UniqueConstraintError) {
|
||||||
ctx.redirect(`/?notice=auth-error`);
|
ctx.redirect(`/?notice=auth-error&error=team-exists`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
invariant(team, "Team must exist");
|
|
||||||
|
invariant(result, "Team creator result must exist");
|
||||||
|
const { team, isNewTeam, authenticationProvider } = result;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [user, isFirstSignin] = await User.findOrCreate({
|
const result = await userCreator({
|
||||||
where: {
|
name: profile.data.name,
|
||||||
[Op.or]: [
|
email: profile.data.email,
|
||||||
{
|
isAdmin: isNewTeam,
|
||||||
service: "google",
|
avatarUrl: profile.data.picture,
|
||||||
serviceId: profile.data.id,
|
teamId: team.id,
|
||||||
},
|
ip: ctx.request.ip,
|
||||||
{
|
authentication: {
|
||||||
service: { [Op.eq]: null },
|
authenticationProviderId: authenticationProvider.id,
|
||||||
email: profile.data.email,
|
providerId: profile.data.id,
|
||||||
},
|
accessToken: response.tokens.access_token,
|
||||||
],
|
refreshToken: response.tokens.refresh_token,
|
||||||
teamId: team.id,
|
scopes: response.tokens.scope.split(" "),
|
||||||
},
|
|
||||||
defaults: {
|
|
||||||
service: "google",
|
|
||||||
serviceId: profile.data.id,
|
|
||||||
name: profile.data.name,
|
|
||||||
email: profile.data.email,
|
|
||||||
isAdmin: isFirstUser,
|
|
||||||
avatarUrl: profile.data.picture,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// update the user with fresh details if they just accepted an invite
|
const { user, isNewUser } = result;
|
||||||
if (!user.serviceId || !user.service) {
|
|
||||||
await user.update({
|
|
||||||
service: "google",
|
|
||||||
serviceId: profile.data.id,
|
|
||||||
avatarUrl: profile.data.picture,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// update email address if it's changed in Google
|
if (isNewTeam) {
|
||||||
if (!isFirstSignin && profile.data.email !== user.email) {
|
|
||||||
await user.update({ email: profile.data.email });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFirstUser) {
|
|
||||||
await team.provisionFirstCollection(user.id);
|
await team.provisionFirstCollection(user.id);
|
||||||
await team.provisionSubdomain(hostname);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// set cookies on response and redirect to team subdomain
|
// set cookies on response and redirect to team subdomain
|
||||||
ctx.signIn(user, team, "google", isFirstSignin);
|
ctx.signIn(user, team, "google", isNewUser);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Sequelize.UniqueConstraintError) {
|
if (err instanceof Sequelize.UniqueConstraintError) {
|
||||||
const exists = await User.findOne({
|
const exists = await User.findOne({
|
||||||
where: {
|
where: {
|
||||||
service: "email",
|
|
||||||
email: profile.data.email,
|
email: profile.data.email,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
},
|
},
|
||||||
|
@ -147,6 +117,11 @@ router.get("google.callback", auth({ required: false }), async (ctx) => {
|
||||||
if (exists) {
|
if (exists) {
|
||||||
ctx.redirect(`${team.url}?notice=email-auth-required`);
|
ctx.redirect(`${team.url}?notice=email-auth-required`);
|
||||||
} else {
|
} else {
|
||||||
|
if (process.env.SENTRY_DSN) {
|
||||||
|
Sentry.captureException(err);
|
||||||
|
} else {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
ctx.redirect(`${team.url}?notice=auth-error`);
|
ctx.redirect(`${team.url}?notice=auth-error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
import * as Sentry from "@sentry/node";
|
||||||
import addHours from "date-fns/add_hours";
|
import addHours from "date-fns/add_hours";
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import Sequelize from "sequelize";
|
import Sequelize from "sequelize";
|
||||||
import { slackAuth } from "../../shared/utils/routeHelpers";
|
import { slackAuth } from "../../shared/utils/routeHelpers";
|
||||||
|
import teamCreator from "../commands/teamCreator";
|
||||||
|
import userCreator from "../commands/userCreator";
|
||||||
import auth from "../middlewares/authentication";
|
import auth from "../middlewares/authentication";
|
||||||
import { Authentication, Collection, Integration, User, Team } from "../models";
|
import { Authentication, Collection, Integration, User, Team } from "../models";
|
||||||
import * as Slack from "../slack";
|
import * as Slack from "../slack";
|
||||||
import { getCookieDomain } from "../utils/domains";
|
import { getCookieDomain } from "../utils/domains";
|
||||||
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
// start the oauth process and redirect user to Slack
|
// start the oauth process and redirect user to Slack
|
||||||
|
@ -41,76 +43,56 @@ router.get("slack.callback", auth({ required: false }), async (ctx) => {
|
||||||
|
|
||||||
const data = await Slack.oauthAccess(code);
|
const data = await Slack.oauthAccess(code);
|
||||||
|
|
||||||
let team, isFirstUser;
|
let result;
|
||||||
try {
|
try {
|
||||||
[team, isFirstUser] = await Team.findOrCreate({
|
result = await teamCreator({
|
||||||
where: {
|
name: data.team.name,
|
||||||
slackId: data.team.id,
|
subdomain: data.team.domain,
|
||||||
},
|
avatarUrl: data.team.image_230,
|
||||||
defaults: {
|
authenticationProvider: {
|
||||||
name: data.team.name,
|
name: "slack",
|
||||||
avatarUrl: data.team.image_88,
|
providerId: data.team.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Sequelize.UniqueConstraintError) {
|
if (err instanceof Sequelize.UniqueConstraintError) {
|
||||||
ctx.redirect(`/?notice=auth-error`);
|
ctx.redirect(`/?notice=auth-error&error=team-exists`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
invariant(team, "Team must exist");
|
|
||||||
|
invariant(result, "Team creator result must exist");
|
||||||
|
const { authenticationProvider, team, isNewTeam } = result;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [user, isFirstSignin] = await User.findOrCreate({
|
const result = await userCreator({
|
||||||
where: {
|
name: data.user.name,
|
||||||
[Op.or]: [
|
email: data.user.email,
|
||||||
{
|
isAdmin: isNewTeam,
|
||||||
service: "slack",
|
avatarUrl: data.user.image_192,
|
||||||
serviceId: data.user.id,
|
teamId: team.id,
|
||||||
},
|
ip: ctx.request.ip,
|
||||||
{
|
authentication: {
|
||||||
service: { [Op.eq]: null },
|
authenticationProviderId: authenticationProvider.id,
|
||||||
email: data.user.email,
|
providerId: data.user.id,
|
||||||
},
|
accessToken: data.access_token,
|
||||||
],
|
scopes: data.scope.split(","),
|
||||||
teamId: team.id,
|
|
||||||
},
|
|
||||||
defaults: {
|
|
||||||
service: "slack",
|
|
||||||
serviceId: data.user.id,
|
|
||||||
name: data.user.name,
|
|
||||||
email: data.user.email,
|
|
||||||
isAdmin: isFirstUser,
|
|
||||||
avatarUrl: data.user.image_192,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// update the user with fresh details if they just accepted an invite
|
const { user, isNewUser } = result;
|
||||||
if (!user.serviceId || !user.service) {
|
|
||||||
await user.update({
|
|
||||||
service: "slack",
|
|
||||||
serviceId: data.user.id,
|
|
||||||
avatarUrl: data.user.image_192,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// update email address if it's changed in Slack
|
if (isNewTeam) {
|
||||||
if (!isFirstSignin && data.user.email !== user.email) {
|
|
||||||
await user.update({ email: data.user.email });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFirstUser) {
|
|
||||||
await team.provisionFirstCollection(user.id);
|
await team.provisionFirstCollection(user.id);
|
||||||
await team.provisionSubdomain(data.team.domain);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// set cookies on response and redirect to team subdomain
|
// set cookies on response and redirect to team subdomain
|
||||||
ctx.signIn(user, team, "slack", isFirstSignin);
|
ctx.signIn(user, team, "slack", isNewUser);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Sequelize.UniqueConstraintError) {
|
if (err instanceof Sequelize.UniqueConstraintError) {
|
||||||
const exists = await User.findOne({
|
const exists = await User.findOne({
|
||||||
where: {
|
where: {
|
||||||
service: "email",
|
|
||||||
email: data.user.email,
|
email: data.user.email,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
},
|
},
|
||||||
|
@ -119,6 +101,11 @@ router.get("slack.callback", auth({ required: false }), async (ctx) => {
|
||||||
if (exists) {
|
if (exists) {
|
||||||
ctx.redirect(`${team.url}?notice=email-auth-required`);
|
ctx.redirect(`${team.url}?notice=email-auth-required`);
|
||||||
} else {
|
} else {
|
||||||
|
if (process.env.SENTRY_DSN) {
|
||||||
|
Sentry.captureException(err);
|
||||||
|
} else {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
ctx.redirect(`${team.url}?notice=auth-error`);
|
ctx.redirect(`${team.url}?notice=auth-error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
// @flow
|
||||||
|
import debug from "debug";
|
||||||
|
import { Team, AuthenticationProvider } from "../models";
|
||||||
|
import { sequelize } from "../sequelize";
|
||||||
|
import { generateAvatarUrl } from "../utils/avatars";
|
||||||
|
|
||||||
|
const log = debug("server");
|
||||||
|
|
||||||
|
type TeamCreatorResult = {|
|
||||||
|
team: Team,
|
||||||
|
authenticationProvider: AuthenticationProvider,
|
||||||
|
isNewTeam: boolean,
|
||||||
|
|};
|
||||||
|
|
||||||
|
export default async function teamCreator({
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
subdomain,
|
||||||
|
avatarUrl,
|
||||||
|
authenticationProvider,
|
||||||
|
}: {|
|
||||||
|
name: string,
|
||||||
|
domain?: string,
|
||||||
|
subdomain: string,
|
||||||
|
avatarUrl?: string,
|
||||||
|
authenticationProvider: {|
|
||||||
|
name: string,
|
||||||
|
providerId: string,
|
||||||
|
|},
|
||||||
|
|}): Promise<TeamCreatorResult> {
|
||||||
|
const authP = await AuthenticationProvider.findOne({
|
||||||
|
where: authenticationProvider,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Team,
|
||||||
|
as: "team",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// This authentication provider already exists which means we have a team and
|
||||||
|
// there is nothing left to do but return the existing credentials
|
||||||
|
if (authP) {
|
||||||
|
return {
|
||||||
|
authenticationProvider: authP,
|
||||||
|
team: authP.team,
|
||||||
|
isNewTeam: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the service did not provide a logo/avatar then we attempt to generate
|
||||||
|
// one via ClearBit, or fallback to colored initials in worst case scenario
|
||||||
|
if (!avatarUrl) {
|
||||||
|
avatarUrl = await generateAvatarUrl({
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
id: subdomain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// This team has never been seen before, time to create all the new stuff
|
||||||
|
let transaction = await sequelize.transaction();
|
||||||
|
let team;
|
||||||
|
try {
|
||||||
|
team = await Team.create(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
avatarUrl,
|
||||||
|
authenticationProviders: [authenticationProvider],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
include: "authenticationProviders",
|
||||||
|
transaction,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await team.provisionSubdomain(subdomain);
|
||||||
|
} catch (err) {
|
||||||
|
log(`Provisioning subdomain failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
team,
|
||||||
|
authenticationProvider: team.authenticationProviders[0],
|
||||||
|
isNewTeam: true,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
// @flow
|
||||||
|
import { buildTeam } from "../test/factories";
|
||||||
|
import { flushdb } from "../test/support";
|
||||||
|
import teamCreator from "./teamCreator";
|
||||||
|
|
||||||
|
jest.mock("aws-sdk", () => {
|
||||||
|
const mS3 = { putObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
||||||
|
return {
|
||||||
|
S3: jest.fn(() => mS3),
|
||||||
|
Endpoint: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
|
||||||
|
describe("teamCreator", () => {
|
||||||
|
it("should create team and authentication provider", async () => {
|
||||||
|
const result = await teamCreator({
|
||||||
|
name: "Test team",
|
||||||
|
subdomain: "example",
|
||||||
|
avatarUrl: "http://example.com/logo.png",
|
||||||
|
authenticationProvider: {
|
||||||
|
name: "google",
|
||||||
|
providerId: "example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { team, authenticationProvider, isNewTeam } = result;
|
||||||
|
|
||||||
|
expect(authenticationProvider.name).toEqual("google");
|
||||||
|
expect(authenticationProvider.providerId).toEqual("example.com");
|
||||||
|
expect(team.name).toEqual("Test team");
|
||||||
|
expect(team.subdomain).toEqual("example");
|
||||||
|
expect(isNewTeam).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return exising team", async () => {
|
||||||
|
const authenticationProvider = {
|
||||||
|
name: "google",
|
||||||
|
providerId: "example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await buildTeam({
|
||||||
|
subdomain: "example",
|
||||||
|
authenticationProviders: [authenticationProvider],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await teamCreator({
|
||||||
|
name: "Updated name",
|
||||||
|
subdomain: "example",
|
||||||
|
authenticationProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { team, isNewTeam } = result;
|
||||||
|
|
||||||
|
expect(team.id).toEqual(existing.id);
|
||||||
|
expect(team.name).toEqual(existing.name);
|
||||||
|
expect(team.subdomain).toEqual("example");
|
||||||
|
expect(isNewTeam).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,151 @@
|
||||||
|
// @flow
|
||||||
|
import Sequelize from "sequelize";
|
||||||
|
import { Event, User, UserAuthentication } from "../models";
|
||||||
|
import { sequelize } from "../sequelize";
|
||||||
|
|
||||||
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
|
type UserCreatorResult = {|
|
||||||
|
user: User,
|
||||||
|
isNewUser: boolean,
|
||||||
|
authentication: UserAuthentication,
|
||||||
|
|};
|
||||||
|
|
||||||
|
export default async function userCreator({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
isAdmin,
|
||||||
|
avatarUrl,
|
||||||
|
teamId,
|
||||||
|
authentication,
|
||||||
|
ip,
|
||||||
|
}: {|
|
||||||
|
name: string,
|
||||||
|
email: string,
|
||||||
|
isAdmin?: boolean,
|
||||||
|
avatarUrl?: string,
|
||||||
|
teamId: string,
|
||||||
|
ip: string,
|
||||||
|
authentication: {|
|
||||||
|
authenticationProviderId: string,
|
||||||
|
providerId: string,
|
||||||
|
scopes: string[],
|
||||||
|
accessToken?: string,
|
||||||
|
refreshToken?: string,
|
||||||
|
|},
|
||||||
|
|}): Promise<UserCreatorResult> {
|
||||||
|
const { authenticationProviderId, providerId, ...rest } = authentication;
|
||||||
|
const auth = await UserAuthentication.findOne({
|
||||||
|
where: {
|
||||||
|
authenticationProviderId,
|
||||||
|
providerId,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Someone has signed in with this authentication before, we just
|
||||||
|
// want to update the details instead of creating a new record
|
||||||
|
if (auth) {
|
||||||
|
const { user } = auth;
|
||||||
|
|
||||||
|
await user.update({ email });
|
||||||
|
await auth.update(rest);
|
||||||
|
|
||||||
|
return { user, authentication: auth, isNewUser: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A `user` record might exist in the form of an invite even if there is no
|
||||||
|
// existing authentication record that matches. In Outline an invite is a
|
||||||
|
// shell user record.
|
||||||
|
const invite = await User.findOne({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
teamId,
|
||||||
|
lastActiveAt: {
|
||||||
|
[Op.eq]: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: UserAuthentication,
|
||||||
|
as: "authentications",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// We have an existing invite for his user, so we need to update it with our
|
||||||
|
// new details and link up the authentication method
|
||||||
|
if (invite && !invite.authentications.length) {
|
||||||
|
let transaction = await sequelize.transaction();
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
await invite.update(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
avatarUrl,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
auth = await invite.createAuthentication(authentication, {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user: invite, authentication: auth, isNewUser: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No auth, no user – this is an entirely new sign in.
|
||||||
|
let transaction = await sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await User.create(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
isAdmin,
|
||||||
|
teamId,
|
||||||
|
avatarUrl,
|
||||||
|
service: null,
|
||||||
|
authentications: [authentication],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
include: "authentications",
|
||||||
|
transaction,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await Event.create(
|
||||||
|
{
|
||||||
|
name: "users.create",
|
||||||
|
actorId: user.id,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
data: {
|
||||||
|
name: user.name,
|
||||||
|
},
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await transaction.commit();
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
authentication: user.authentications[0],
|
||||||
|
isNewUser: true,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
// @flow
|
||||||
|
import { buildUser, buildTeam, buildInvite } from "../test/factories";
|
||||||
|
import { flushdb } from "../test/support";
|
||||||
|
import userCreator from "./userCreator";
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
|
||||||
|
describe("userCreator", () => {
|
||||||
|
const ip = "127.0.0.1";
|
||||||
|
|
||||||
|
it("should update exising user and authentication", async () => {
|
||||||
|
const existing = await buildUser();
|
||||||
|
const authentications = await existing.getAuthentications();
|
||||||
|
const existingAuth = authentications[0];
|
||||||
|
const newEmail = "test@example.com";
|
||||||
|
|
||||||
|
const result = await userCreator({
|
||||||
|
name: existing.name,
|
||||||
|
email: newEmail,
|
||||||
|
avatarUrl: existing.avatarUrl,
|
||||||
|
teamId: existing.teamId,
|
||||||
|
ip,
|
||||||
|
authentication: {
|
||||||
|
authenticationProviderId: existingAuth.authenticationProviderId,
|
||||||
|
providerId: existingAuth.providerId,
|
||||||
|
accessToken: "123",
|
||||||
|
scopes: ["read"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { user, authentication, isNewUser } = result;
|
||||||
|
|
||||||
|
expect(authentication.accessToken).toEqual("123");
|
||||||
|
expect(authentication.scopes.length).toEqual(1);
|
||||||
|
expect(authentication.scopes[0]).toEqual("read");
|
||||||
|
expect(user.email).toEqual(newEmail);
|
||||||
|
expect(isNewUser).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a new user", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const authenticationProviders = await team.getAuthenticationProviders();
|
||||||
|
const authenticationProvider = authenticationProviders[0];
|
||||||
|
|
||||||
|
const result = await userCreator({
|
||||||
|
name: "Test Name",
|
||||||
|
email: "test@example.com",
|
||||||
|
teamId: team.id,
|
||||||
|
ip,
|
||||||
|
authentication: {
|
||||||
|
authenticationProviderId: authenticationProvider.id,
|
||||||
|
providerId: "fake-service-id",
|
||||||
|
accessToken: "123",
|
||||||
|
scopes: ["read"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { user, authentication, isNewUser } = result;
|
||||||
|
|
||||||
|
expect(authentication.accessToken).toEqual("123");
|
||||||
|
expect(authentication.scopes.length).toEqual(1);
|
||||||
|
expect(authentication.scopes[0]).toEqual("read");
|
||||||
|
expect(user.email).toEqual("test@example.com");
|
||||||
|
expect(isNewUser).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a user from an invited user", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const invite = await buildInvite({ teamId: team.id });
|
||||||
|
const authenticationProviders = await team.getAuthenticationProviders();
|
||||||
|
const authenticationProvider = authenticationProviders[0];
|
||||||
|
|
||||||
|
const result = await userCreator({
|
||||||
|
name: invite.name,
|
||||||
|
email: invite.email,
|
||||||
|
teamId: invite.teamId,
|
||||||
|
ip,
|
||||||
|
authentication: {
|
||||||
|
authenticationProviderId: authenticationProvider.id,
|
||||||
|
providerId: "fake-service-id",
|
||||||
|
accessToken: "123",
|
||||||
|
scopes: ["read"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { user, authentication, isNewUser } = result;
|
||||||
|
|
||||||
|
expect(authentication.accessToken).toEqual("123");
|
||||||
|
expect(authentication.scopes.length).toEqual(1);
|
||||||
|
expect(authentication.scopes[0]).toEqual("read");
|
||||||
|
expect(user.email).toEqual(invite.email);
|
||||||
|
expect(isNewUser).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
|
@ -18,7 +18,6 @@ if (
|
||||||
console.error(
|
console.error(
|
||||||
"The SECRET_KEY env variable must be set with the output of `openssl rand -hex 32`"
|
"The SECRET_KEY env variable must be set with the output of `openssl rand -hex 32`"
|
||||||
);
|
);
|
||||||
// $FlowFixMe
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,7 +30,6 @@ if (process.env.AWS_ACCESS_KEY_ID) {
|
||||||
].forEach((key) => {
|
].forEach((key) => {
|
||||||
if (!process.env[key]) {
|
if (!process.env[key]) {
|
||||||
console.error(`The ${key} env variable must be set when using AWS`);
|
console.error(`The ${key} env variable must be set when using AWS`);
|
||||||
// $FlowFixMe
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -42,7 +40,6 @@ if (process.env.SLACK_KEY) {
|
||||||
console.error(
|
console.error(
|
||||||
`The SLACK_SECRET env variable must be set when using Slack Sign In`
|
`The SLACK_SECRET env variable must be set when using Slack Sign In`
|
||||||
);
|
);
|
||||||
// $FlowFixMe
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -51,7 +48,6 @@ if (!process.env.URL) {
|
||||||
console.error(
|
console.error(
|
||||||
"The URL env variable must be set to the externally accessible URL, e.g (https://www.getoutline.com)"
|
"The URL env variable must be set to the externally accessible URL, e.g (https://www.getoutline.com)"
|
||||||
);
|
);
|
||||||
// $FlowFixMe
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,7 +55,6 @@ if (!process.env.DATABASE_URL) {
|
||||||
console.error(
|
console.error(
|
||||||
"The DATABASE_URL env variable must be set to the location of your postgres server, including authentication and port"
|
"The DATABASE_URL env variable must be set to the location of your postgres server, including authentication and port"
|
||||||
);
|
);
|
||||||
// $FlowFixMe
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,7 +62,6 @@ if (!process.env.REDIS_URL) {
|
||||||
console.error(
|
console.error(
|
||||||
"The REDIS_URL env variable must be set to the location of your redis server, including authentication and port"
|
"The REDIS_URL env variable must be set to the location of your redis server, including authentication and port"
|
||||||
);
|
);
|
||||||
// $FlowFixMe
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { Document, Collection, View } from "./models";
|
||||||
import policy from "./policies";
|
import policy from "./policies";
|
||||||
import { client, subscriber } from "./redis";
|
import { client, subscriber } from "./redis";
|
||||||
import { getUserForJWT } from "./utils/jwt";
|
import { getUserForJWT } from "./utils/jwt";
|
||||||
|
import { checkMigrations } from "./utils/startup";
|
||||||
|
|
||||||
const server = http.createServer(app.callback());
|
const server = http.createServer(app.callback());
|
||||||
let io;
|
let io;
|
||||||
|
@ -191,7 +192,10 @@ server.on("listening", () => {
|
||||||
console.log(`\n> Listening on http://localhost:${address.port}\n`);
|
console.log(`\n> Listening on http://localhost:${address.port}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(process.env.PORT || "3000");
|
(async () => {
|
||||||
|
await checkMigrations();
|
||||||
|
server.listen(process.env.PORT || "3000");
|
||||||
|
})();
|
||||||
|
|
||||||
export const socketio = io;
|
export const socketio = io;
|
||||||
|
|
||||||
|
|
|
@ -102,31 +102,18 @@ export default function auth(options?: { required?: boolean } = {}) {
|
||||||
// update the database when the user last signed in
|
// update the database when the user last signed in
|
||||||
user.updateSignedIn(ctx.request.ip);
|
user.updateSignedIn(ctx.request.ip);
|
||||||
|
|
||||||
if (isFirstSignin) {
|
// don't await event creation for a faster sign-in
|
||||||
Event.create({
|
Event.create({
|
||||||
name: "users.create",
|
name: "users.signin",
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
data: {
|
data: {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
service,
|
service,
|
||||||
},
|
},
|
||||||
ip: ctx.request.ip,
|
ip: ctx.request.ip,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
Event.create({
|
|
||||||
name: "users.signin",
|
|
||||||
actorId: user.id,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team.id,
|
|
||||||
data: {
|
|
||||||
name: user.name,
|
|
||||||
service,
|
|
||||||
},
|
|
||||||
ip: ctx.request.ip,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = getCookieDomain(ctx.request.hostname);
|
const domain = getCookieDomain(ctx.request.hostname);
|
||||||
const expires = addMonths(new Date(), 3);
|
const expires = addMonths(new Date(), 3);
|
||||||
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.createTable("authentication_providers", {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
providerId: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
unique: true,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true
|
||||||
|
},
|
||||||
|
teamId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: "teams"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.createTable("user_authentications", {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: "users"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
authenticationProviderId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: "authentication_providers"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
accessToken: {
|
||||||
|
type: Sequelize.BLOB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
refreshToken: {
|
||||||
|
type: Sequelize.BLOB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
scopes: {
|
||||||
|
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
providerId: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
unique: true,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.removeColumn("users", "slackAccessToken")
|
||||||
|
await queryInterface.addIndex("authentication_providers", ["providerId"]);
|
||||||
|
await queryInterface.addIndex("user_authentications", ["providerId"]);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.dropTable("user_authentications");
|
||||||
|
await queryInterface.dropTable("authentication_providers");
|
||||||
|
await queryInterface.addColumn("users", "slackAccessToken", {
|
||||||
|
type: 'bytea',
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,51 @@
|
||||||
|
// @flow
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { DataTypes, sequelize } from "../sequelize";
|
||||||
|
|
||||||
|
// Each authentication provider must have a definition under server/auth, the
|
||||||
|
// name of the file will be used as reference in the db, one less thing to config
|
||||||
|
const authProviders = fs
|
||||||
|
.readdirSync(path.resolve(__dirname, "..", "auth"))
|
||||||
|
.filter(
|
||||||
|
(file) =>
|
||||||
|
file.indexOf(".") !== 0 &&
|
||||||
|
!file.includes(".test") &&
|
||||||
|
!file.includes("index.js")
|
||||||
|
)
|
||||||
|
.map((fileName) => fileName.replace(".js", ""));
|
||||||
|
|
||||||
|
const AuthenticationProvider = sequelize.define(
|
||||||
|
"authentication_providers",
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
validate: {
|
||||||
|
isIn: [authProviders],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
providerId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
updatedAt: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
AuthenticationProvider.associate = (models) => {
|
||||||
|
AuthenticationProvider.belongsTo(models.Team);
|
||||||
|
AuthenticationProvider.hasMany(models.UserAuthentication);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthenticationProvider;
|
|
@ -249,9 +249,7 @@ describe("#membershipUserIds", () => {
|
||||||
const users = await Promise.all(
|
const users = await Promise.all(
|
||||||
Array(6)
|
Array(6)
|
||||||
.fill()
|
.fill()
|
||||||
.map(() => {
|
.map(() => buildUser({ teamId }))
|
||||||
return buildUser({ teamId });
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const collection = await buildCollection({
|
const collection = await buildCollection({
|
||||||
|
|
|
@ -96,6 +96,14 @@ Team.associate = (models) => {
|
||||||
Team.hasMany(models.Collection, { as: "collections" });
|
Team.hasMany(models.Collection, { as: "collections" });
|
||||||
Team.hasMany(models.Document, { as: "documents" });
|
Team.hasMany(models.Document, { as: "documents" });
|
||||||
Team.hasMany(models.User, { as: "users" });
|
Team.hasMany(models.User, { as: "users" });
|
||||||
|
Team.hasMany(models.AuthenticationProvider, {
|
||||||
|
as: "authenticationProviders",
|
||||||
|
});
|
||||||
|
Team.addScope("withAuthenticationProviders", {
|
||||||
|
include: [
|
||||||
|
{ model: models.AuthenticationProvider, as: "authenticationProviders" },
|
||||||
|
],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadAvatar = async (model) => {
|
const uploadAvatar = async (model) => {
|
||||||
|
@ -121,13 +129,13 @@ const uploadAvatar = async (model) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Team.prototype.provisionSubdomain = async function (subdomain) {
|
Team.prototype.provisionSubdomain = async function (subdomain, options = {}) {
|
||||||
if (this.subdomain) return this.subdomain;
|
if (this.subdomain) return this.subdomain;
|
||||||
|
|
||||||
let append = 0;
|
let append = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
await this.update({ subdomain });
|
await this.update({ subdomain }, options);
|
||||||
break;
|
break;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// subdomain was invalid or already used, try again
|
// subdomain was invalid or already used, try again
|
||||||
|
|
|
@ -79,7 +79,12 @@ User.associate = (models) => {
|
||||||
});
|
});
|
||||||
User.hasMany(models.Document, { as: "documents" });
|
User.hasMany(models.Document, { as: "documents" });
|
||||||
User.hasMany(models.View, { as: "views" });
|
User.hasMany(models.View, { as: "views" });
|
||||||
|
User.hasMany(models.UserAuthentication, { as: "authentications" });
|
||||||
User.belongsTo(models.Team);
|
User.belongsTo(models.Team);
|
||||||
|
|
||||||
|
User.addScope("withAuthentications", {
|
||||||
|
include: [{ model: models.UserAuthentication, as: "authentications" }],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Instance methods
|
// Instance methods
|
||||||
|
@ -151,10 +156,6 @@ User.prototype.getTransferToken = function () {
|
||||||
// Returns a temporary token that is only used for logging in from an email
|
// Returns a temporary token that is only used for logging in from an email
|
||||||
// It can only be used to sign in once and has a medium length expiry
|
// It can only be used to sign in once and has a medium length expiry
|
||||||
User.prototype.getEmailSigninToken = function () {
|
User.prototype.getEmailSigninToken = function () {
|
||||||
if (this.service && this.service !== "email") {
|
|
||||||
throw new Error("Cannot generate email signin token for OAuth user");
|
|
||||||
}
|
|
||||||
|
|
||||||
return JWT.sign(
|
return JWT.sign(
|
||||||
{ id: this.id, createdAt: new Date().toISOString(), type: "email-signin" },
|
{ id: this.id, createdAt: new Date().toISOString(), type: "email-signin" },
|
||||||
this.jwtSecret
|
this.jwtSecret
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
// @flow
|
||||||
|
import { DataTypes, sequelize, encryptedFields } from "../sequelize";
|
||||||
|
|
||||||
|
const UserAuthentication = sequelize.define("user_authentications", {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
scopes: DataTypes.ARRAY(DataTypes.STRING),
|
||||||
|
accessToken: encryptedFields().vault("accessToken"),
|
||||||
|
refreshToken: encryptedFields().vault("refreshToken"),
|
||||||
|
providerId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
UserAuthentication.associate = (models) => {
|
||||||
|
UserAuthentication.belongsTo(models.AuthenticationProvider);
|
||||||
|
UserAuthentication.belongsTo(models.User);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserAuthentication;
|
|
@ -2,6 +2,7 @@
|
||||||
import ApiKey from "./ApiKey";
|
import ApiKey from "./ApiKey";
|
||||||
import Attachment from "./Attachment";
|
import Attachment from "./Attachment";
|
||||||
import Authentication from "./Authentication";
|
import Authentication from "./Authentication";
|
||||||
|
import AuthenticationProvider from "./AuthenticationProvider";
|
||||||
import Backlink from "./Backlink";
|
import Backlink from "./Backlink";
|
||||||
import Collection from "./Collection";
|
import Collection from "./Collection";
|
||||||
import CollectionGroup from "./CollectionGroup";
|
import CollectionGroup from "./CollectionGroup";
|
||||||
|
@ -19,12 +20,14 @@ import Share from "./Share";
|
||||||
import Star from "./Star";
|
import Star from "./Star";
|
||||||
import Team from "./Team";
|
import Team from "./Team";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
|
import UserAuthentication from "./UserAuthentication";
|
||||||
import View from "./View";
|
import View from "./View";
|
||||||
|
|
||||||
const models = {
|
const models = {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
Attachment,
|
Attachment,
|
||||||
Authentication,
|
Authentication,
|
||||||
|
AuthenticationProvider,
|
||||||
Backlink,
|
Backlink,
|
||||||
Collection,
|
Collection,
|
||||||
CollectionGroup,
|
CollectionGroup,
|
||||||
|
@ -42,6 +45,7 @@ const models = {
|
||||||
Star,
|
Star,
|
||||||
Team,
|
Team,
|
||||||
User,
|
User,
|
||||||
|
UserAuthentication,
|
||||||
View,
|
View,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -56,6 +60,7 @@ export {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
Attachment,
|
Attachment,
|
||||||
Authentication,
|
Authentication,
|
||||||
|
AuthenticationProvider,
|
||||||
Backlink,
|
Backlink,
|
||||||
Collection,
|
Collection,
|
||||||
CollectionGroup,
|
CollectionGroup,
|
||||||
|
@ -73,5 +78,6 @@ export {
|
||||||
Star,
|
Star,
|
||||||
Team,
|
Team,
|
||||||
User,
|
User,
|
||||||
|
UserAuthentication,
|
||||||
View,
|
View,
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,8 +6,6 @@ export default function present(team: Team) {
|
||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
avatarUrl: team.logoUrl,
|
avatarUrl: team.logoUrl,
|
||||||
slackConnected: !!team.slackId,
|
|
||||||
googleConnected: !!team.googleId,
|
|
||||||
sharing: team.sharing,
|
sharing: team.sharing,
|
||||||
documentEmbeds: team.documentEmbeds,
|
documentEmbeds: team.documentEmbeds,
|
||||||
guestSignin: team.guestSignin,
|
guestSignin: team.guestSignin,
|
||||||
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
// @flow
|
||||||
|
import "./bootstrap";
|
||||||
|
import debug from "debug";
|
||||||
|
import {
|
||||||
|
Team,
|
||||||
|
User,
|
||||||
|
AuthenticationProvider,
|
||||||
|
UserAuthentication,
|
||||||
|
} from "../models";
|
||||||
|
import { Op } from "../sequelize";
|
||||||
|
|
||||||
|
const log = debug("server");
|
||||||
|
const cache = {};
|
||||||
|
let page = 0;
|
||||||
|
let limit = 100;
|
||||||
|
|
||||||
|
export default async function main(exit = false) {
|
||||||
|
const work = async (page: number) => {
|
||||||
|
log(`Migrating authentication data… page ${page}`);
|
||||||
|
|
||||||
|
const users = await User.findAll({
|
||||||
|
limit,
|
||||||
|
offset: page * limit,
|
||||||
|
paranoid: false,
|
||||||
|
order: [["createdAt", "ASC"]],
|
||||||
|
where: {
|
||||||
|
serviceId: {
|
||||||
|
[Op.ne]: "email",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Team,
|
||||||
|
as: "team",
|
||||||
|
required: true,
|
||||||
|
paranoid: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const provider = user.service;
|
||||||
|
const providerId = user.team[`${provider}Id`];
|
||||||
|
if (!providerId) {
|
||||||
|
console.error(
|
||||||
|
`user ${user.id} has serviceId ${user.serviceId}, but team ${provider}Id missing`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (providerId.startsWith("transferred")) {
|
||||||
|
console.log(
|
||||||
|
`skipping previously transferred ${user.team.name} (${user.team.id})`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authenticationProviderId = cache[providerId];
|
||||||
|
if (!authenticationProviderId) {
|
||||||
|
const [
|
||||||
|
authenticationProvider,
|
||||||
|
] = await AuthenticationProvider.findOrCreate({
|
||||||
|
where: {
|
||||||
|
name: provider,
|
||||||
|
providerId,
|
||||||
|
teamId: user.teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cache[providerId] = authenticationProviderId =
|
||||||
|
authenticationProvider.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await UserAuthentication.create({
|
||||||
|
authenticationProviderId,
|
||||||
|
providerId: user.serviceId,
|
||||||
|
teamId: user.teamId,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`serviceId ${user.serviceId} exists, for user ${user.id}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.length === limit ? work(page + 1) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
await work(page);
|
||||||
|
|
||||||
|
if (exit) {
|
||||||
|
log("Migration complete");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In the test suite we import the script rather than run via node CLI
|
||||||
|
if (process.env.NODE_ENV !== "test") {
|
||||||
|
main(true);
|
||||||
|
}
|
|
@ -0,0 +1,152 @@
|
||||||
|
// @flow
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Team,
|
||||||
|
UserAuthentication,
|
||||||
|
AuthenticationProvider,
|
||||||
|
} from "../models";
|
||||||
|
import { flushdb } from "../test/support";
|
||||||
|
import script from "./20210226232041-migrate-authentication";
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
|
||||||
|
describe("#work", () => {
|
||||||
|
it("should create authentication record for users", async () => {
|
||||||
|
const team = await Team.create({
|
||||||
|
name: `Test`,
|
||||||
|
slackId: "T123",
|
||||||
|
});
|
||||||
|
const user = await User.create({
|
||||||
|
email: `test@example.com`,
|
||||||
|
name: `Test`,
|
||||||
|
serviceId: "U123",
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await script();
|
||||||
|
|
||||||
|
const authProvider = await AuthenticationProvider.findOne({
|
||||||
|
where: {
|
||||||
|
providerId: "T123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = await UserAuthentication.findOne({
|
||||||
|
where: {
|
||||||
|
providerId: "U123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(authProvider.name).toEqual("slack");
|
||||||
|
expect(auth.userId).toEqual(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create authentication record for deleted users", async () => {
|
||||||
|
const team = await Team.create({
|
||||||
|
name: `Test`,
|
||||||
|
googleId: "domain.com",
|
||||||
|
});
|
||||||
|
const user = await User.create({
|
||||||
|
email: `test1@example.com`,
|
||||||
|
name: `Test`,
|
||||||
|
service: "google",
|
||||||
|
serviceId: "123456789",
|
||||||
|
teamId: team.id,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await script();
|
||||||
|
|
||||||
|
const authProvider = await AuthenticationProvider.findOne({
|
||||||
|
where: {
|
||||||
|
providerId: "domain.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = await UserAuthentication.findOne({
|
||||||
|
where: {
|
||||||
|
providerId: "123456789",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(authProvider.name).toEqual("google");
|
||||||
|
expect(auth.userId).toEqual(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create authentication record for suspended users", async () => {
|
||||||
|
const team = await Team.create({
|
||||||
|
name: `Test`,
|
||||||
|
googleId: "example.com",
|
||||||
|
});
|
||||||
|
const user = await User.create({
|
||||||
|
email: `test1@example.com`,
|
||||||
|
name: `Test`,
|
||||||
|
service: "google",
|
||||||
|
serviceId: "123456789",
|
||||||
|
teamId: team.id,
|
||||||
|
suspendedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await script();
|
||||||
|
|
||||||
|
const authProvider = await AuthenticationProvider.findOne({
|
||||||
|
where: {
|
||||||
|
providerId: "example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = await UserAuthentication.findOne({
|
||||||
|
where: {
|
||||||
|
providerId: "123456789",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(authProvider.name).toEqual("google");
|
||||||
|
expect(auth.userId).toEqual(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create correct authentication record when team has both slackId and googleId", async () => {
|
||||||
|
const team = await Team.create({
|
||||||
|
name: `Test`,
|
||||||
|
slackId: "T456",
|
||||||
|
googleId: "example.com",
|
||||||
|
});
|
||||||
|
const user = await User.create({
|
||||||
|
email: `test1@example.com`,
|
||||||
|
name: `Test`,
|
||||||
|
service: "slack",
|
||||||
|
serviceId: "U456",
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await script();
|
||||||
|
|
||||||
|
const authProvider = await AuthenticationProvider.findOne({
|
||||||
|
where: {
|
||||||
|
providerId: "T456",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = await UserAuthentication.findOne({
|
||||||
|
where: {
|
||||||
|
providerId: "U456",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(authProvider.name).toEqual("slack");
|
||||||
|
expect(auth.userId).toEqual(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip invited users", async () => {
|
||||||
|
const team = await Team.create({
|
||||||
|
name: `Test`,
|
||||||
|
slackId: "T789",
|
||||||
|
});
|
||||||
|
await User.create({
|
||||||
|
email: `test2@example.com`,
|
||||||
|
name: `Test`,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await script();
|
||||||
|
|
||||||
|
const count = await UserAuthentication.count();
|
||||||
|
expect(count).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,6 @@
|
||||||
|
// @flow
|
||||||
|
if (process.env.NODE_ENV !== "test") {
|
||||||
|
require("dotenv").config({ silent: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.SINGLE_RUN = true;
|
|
@ -6,20 +6,22 @@ import fs from "fs-extra";
|
||||||
const log = debug("services");
|
const log = debug("services");
|
||||||
const services = {};
|
const services = {};
|
||||||
|
|
||||||
fs.readdirSync(__dirname)
|
if (!process.env.SINGLE_RUN) {
|
||||||
.filter(
|
fs.readdirSync(__dirname)
|
||||||
(file) =>
|
.filter(
|
||||||
file.indexOf(".") !== 0 &&
|
(file) =>
|
||||||
file !== path.basename(__filename) &&
|
file.indexOf(".") !== 0 &&
|
||||||
!file.includes(".test")
|
file !== path.basename(__filename) &&
|
||||||
)
|
!file.includes(".test")
|
||||||
.forEach((fileName) => {
|
)
|
||||||
const servicePath = path.join(__dirname, fileName);
|
.forEach((fileName) => {
|
||||||
const name = path.basename(servicePath.replace(/\.js$/, ""));
|
const servicePath = path.join(__dirname, fileName);
|
||||||
// $FlowIssue
|
const name = path.basename(servicePath.replace(/\.js$/, ""));
|
||||||
const Service = require(servicePath).default;
|
// $FlowIssue
|
||||||
services[name] = new Service();
|
const Service = require(servicePath).default;
|
||||||
log(`loaded ${name} service`);
|
services[name] = new Service();
|
||||||
});
|
log(`loaded ${name} service`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default services;
|
export default services;
|
||||||
|
|
|
@ -12,9 +12,10 @@ import {
|
||||||
Attachment,
|
Attachment,
|
||||||
Authentication,
|
Authentication,
|
||||||
Integration,
|
Integration,
|
||||||
|
AuthenticationProvider,
|
||||||
} from "../models";
|
} from "../models";
|
||||||
|
|
||||||
let count = 0;
|
let count = 1;
|
||||||
|
|
||||||
export async function buildShare(overrides: Object = {}) {
|
export async function buildShare(overrides: Object = {}) {
|
||||||
if (!overrides.teamId) {
|
if (!overrides.teamId) {
|
||||||
|
@ -35,11 +36,21 @@ export async function buildShare(overrides: Object = {}) {
|
||||||
export function buildTeam(overrides: Object = {}) {
|
export function buildTeam(overrides: Object = {}) {
|
||||||
count++;
|
count++;
|
||||||
|
|
||||||
return Team.create({
|
return Team.create(
|
||||||
name: `Team ${count}`,
|
{
|
||||||
slackId: uuid.v4(),
|
name: `Team ${count}`,
|
||||||
...overrides,
|
authenticationProviders: [
|
||||||
});
|
{
|
||||||
|
name: "slack",
|
||||||
|
providerId: uuid.v4(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
include: "authenticationProviders",
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildEvent(overrides: Object = {}) {
|
export function buildEvent(overrides: Object = {}) {
|
||||||
|
@ -51,21 +62,51 @@ export function buildEvent(overrides: Object = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildUser(overrides: Object = {}) {
|
export async function buildUser(overrides: Object = {}) {
|
||||||
count++;
|
|
||||||
|
|
||||||
if (!overrides.teamId) {
|
if (!overrides.teamId) {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
overrides.teamId = team.id;
|
overrides.teamId = team.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authenticationProvider = await AuthenticationProvider.findOne({
|
||||||
|
where: {
|
||||||
|
teamId: overrides.teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
count++;
|
||||||
|
|
||||||
|
return User.create(
|
||||||
|
{
|
||||||
|
email: `user${count}@example.com`,
|
||||||
|
name: `User ${count}`,
|
||||||
|
createdAt: new Date("2018-01-01T00:00:00.000Z"),
|
||||||
|
lastActiveAt: new Date("2018-01-01T00:00:00.000Z"),
|
||||||
|
authentications: [
|
||||||
|
{
|
||||||
|
authenticationProviderId: authenticationProvider.id,
|
||||||
|
providerId: uuid.v4(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
include: "authentications",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildInvite(overrides: Object = {}) {
|
||||||
|
if (!overrides.teamId) {
|
||||||
|
const team = await buildTeam();
|
||||||
|
overrides.teamId = team.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
|
||||||
return User.create({
|
return User.create({
|
||||||
email: `user${count}@example.com`,
|
email: `user${count}@example.com`,
|
||||||
username: `user${count}`,
|
|
||||||
name: `User ${count}`,
|
name: `User ${count}`,
|
||||||
service: "slack",
|
|
||||||
serviceId: uuid.v4(),
|
|
||||||
createdAt: new Date("2018-01-01T00:00:00.000Z"),
|
createdAt: new Date("2018-01-01T00:00:00.000Z"),
|
||||||
lastActiveAt: new Date("2018-01-01T00:00:00.000Z"),
|
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -98,8 +139,6 @@ export async function buildIntegration(overrides: Object = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildCollection(overrides: Object = {}) {
|
export async function buildCollection(overrides: Object = {}) {
|
||||||
count++;
|
|
||||||
|
|
||||||
if (!overrides.teamId) {
|
if (!overrides.teamId) {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
overrides.teamId = team.id;
|
overrides.teamId = team.id;
|
||||||
|
@ -110,6 +149,8 @@ export async function buildCollection(overrides: Object = {}) {
|
||||||
overrides.userId = user.id;
|
overrides.userId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
|
||||||
return Collection.create({
|
return Collection.create({
|
||||||
name: `Test Collection ${count}`,
|
name: `Test Collection ${count}`,
|
||||||
description: "Test collection description",
|
description: "Test collection description",
|
||||||
|
@ -119,8 +160,6 @@ export async function buildCollection(overrides: Object = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildGroup(overrides: Object = {}) {
|
export async function buildGroup(overrides: Object = {}) {
|
||||||
count++;
|
|
||||||
|
|
||||||
if (!overrides.teamId) {
|
if (!overrides.teamId) {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
overrides.teamId = team.id;
|
overrides.teamId = team.id;
|
||||||
|
@ -131,6 +170,8 @@ export async function buildGroup(overrides: Object = {}) {
|
||||||
overrides.userId = user.id;
|
overrides.userId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
|
||||||
return Group.create({
|
return Group.create({
|
||||||
name: `Test Group ${count}`,
|
name: `Test Group ${count}`,
|
||||||
createdById: overrides.userId,
|
createdById: overrides.userId,
|
||||||
|
@ -139,8 +180,6 @@ export async function buildGroup(overrides: Object = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildGroupUser(overrides: Object = {}) {
|
export async function buildGroupUser(overrides: Object = {}) {
|
||||||
count++;
|
|
||||||
|
|
||||||
if (!overrides.teamId) {
|
if (!overrides.teamId) {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
overrides.teamId = team.id;
|
overrides.teamId = team.id;
|
||||||
|
@ -151,6 +190,8 @@ export async function buildGroupUser(overrides: Object = {}) {
|
||||||
overrides.userId = user.id;
|
overrides.userId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
|
||||||
return GroupUser.create({
|
return GroupUser.create({
|
||||||
createdById: overrides.userId,
|
createdById: overrides.userId,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
@ -158,8 +199,6 @@ export async function buildGroupUser(overrides: Object = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildDocument(overrides: Object = {}) {
|
export async function buildDocument(overrides: Object = {}) {
|
||||||
count++;
|
|
||||||
|
|
||||||
if (!overrides.teamId) {
|
if (!overrides.teamId) {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
overrides.teamId = team.id;
|
overrides.teamId = team.id;
|
||||||
|
@ -175,6 +214,8 @@ export async function buildDocument(overrides: Object = {}) {
|
||||||
overrides.collectionId = collection.id;
|
overrides.collectionId = collection.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
|
||||||
return Document.create({
|
return Document.create({
|
||||||
title: `Document ${count}`,
|
title: `Document ${count}`,
|
||||||
text: "This is the text in an example document",
|
text: "This is the text in an example document",
|
||||||
|
@ -186,15 +227,13 @@ export async function buildDocument(overrides: Object = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildAttachment(overrides: Object = {}) {
|
export async function buildAttachment(overrides: Object = {}) {
|
||||||
count++;
|
|
||||||
|
|
||||||
if (!overrides.teamId) {
|
if (!overrides.teamId) {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
overrides.teamId = team.id;
|
overrides.teamId = team.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!overrides.userId) {
|
if (!overrides.userId) {
|
||||||
const user = await buildUser();
|
const user = await buildUser({ teamId: overrides.teamId });
|
||||||
overrides.userId = user.id;
|
overrides.userId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -208,6 +247,8 @@ export async function buildAttachment(overrides: Object = {}) {
|
||||||
overrides.documentId = document.id;
|
overrides.documentId = document.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
|
||||||
return Attachment.create({
|
return Attachment.create({
|
||||||
key: `uploads/key/to/file ${count}.png`,
|
key: `uploads/key/to/file ${count}.png`,
|
||||||
url: `https://redirect.url.com/uploads/key/to/file ${count}.png`,
|
url: `https://redirect.url.com/uploads/key/to/file ${count}.png`,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
import uuid from "uuid";
|
||||||
import { User, Document, Collection, Team } from "../models";
|
import { User, Document, Collection, Team } from "../models";
|
||||||
import { sequelize } from "../sequelize";
|
import { sequelize } from "../sequelize";
|
||||||
|
|
||||||
|
@ -15,49 +16,64 @@ export function flushdb() {
|
||||||
return sequelize.query(query);
|
return sequelize.query(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = async () => {
|
export const seed = async () => {
|
||||||
const team = await Team.create({
|
const team = await Team.create(
|
||||||
id: "86fde1d4-0050-428f-9f0b-0bf77f8bdf61",
|
{
|
||||||
name: "Team",
|
name: "Team",
|
||||||
slackId: "T2399UF2P",
|
authenticationProviders: [
|
||||||
slackData: {
|
{
|
||||||
id: "T2399UF2P",
|
name: "slack",
|
||||||
|
providerId: uuid.v4(),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
include: "authenticationProviders",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const admin = await User.create({
|
const authenticationProvider = team.authenticationProviders[0];
|
||||||
id: "fa952cff-fa64-4d42-a6ea-6955c9689046",
|
|
||||||
email: "admin@example.com",
|
|
||||||
username: "admin",
|
|
||||||
name: "Admin User",
|
|
||||||
teamId: team.id,
|
|
||||||
isAdmin: true,
|
|
||||||
service: "slack",
|
|
||||||
serviceId: "U2399UF1P",
|
|
||||||
slackData: {
|
|
||||||
id: "U2399UF1P",
|
|
||||||
image_192: "http://example.com/avatar.png",
|
|
||||||
},
|
|
||||||
createdAt: new Date("2018-01-01T00:00:00.000Z"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = await User.create({
|
const admin = await User.create(
|
||||||
id: "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
{
|
||||||
email: "user1@example.com",
|
email: "admin@example.com",
|
||||||
username: "user1",
|
username: "admin",
|
||||||
name: "User 1",
|
name: "Admin User",
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
service: "slack",
|
isAdmin: true,
|
||||||
serviceId: "U2399UF2P",
|
createdAt: new Date("2018-01-01T00:00:00.000Z"),
|
||||||
slackData: {
|
authentications: [
|
||||||
id: "U2399UF2P",
|
{
|
||||||
image_192: "http://example.com/avatar.png",
|
authenticationProviderId: authenticationProvider.id,
|
||||||
|
providerId: uuid.v4(),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
createdAt: new Date("2018-01-02T00:00:00.000Z"),
|
{
|
||||||
});
|
include: "authentications",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await User.create(
|
||||||
|
{
|
||||||
|
id: "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||||
|
email: "user1@example.com",
|
||||||
|
name: "User 1",
|
||||||
|
teamId: team.id,
|
||||||
|
createdAt: new Date("2018-01-02T00:00:00.000Z"),
|
||||||
|
authentications: [
|
||||||
|
{
|
||||||
|
authenticationProviderId: authenticationProvider.id,
|
||||||
|
providerId: uuid.v4(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
include: "authentications",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const collection = await Collection.create({
|
const collection = await Collection.create({
|
||||||
id: "26fde1d4-0050-428f-9f0b-0bf77f8bdf62",
|
|
||||||
name: "Collection",
|
name: "Collection",
|
||||||
urlId: "collection",
|
urlId: "collection",
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
|
@ -85,5 +101,3 @@ const seed = async () => {
|
||||||
team,
|
team,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export { seed, sequelize };
|
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
// @flow
|
||||||
|
import crypto from "crypto";
|
||||||
|
import fetch from "isomorphic-fetch";
|
||||||
|
|
||||||
|
export async function generateAvatarUrl({
|
||||||
|
id,
|
||||||
|
domain,
|
||||||
|
name = "Unknown",
|
||||||
|
}: {
|
||||||
|
id: string,
|
||||||
|
domain?: string,
|
||||||
|
name?: string,
|
||||||
|
}) {
|
||||||
|
// attempt to get logo from Clearbit API. If one doesn't exist then
|
||||||
|
// fall back to using tiley to generate a placeholder logo
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
hash.update(id);
|
||||||
|
const hashedId = hash.digest("hex");
|
||||||
|
|
||||||
|
let cbResponse, cbUrl;
|
||||||
|
if (domain) {
|
||||||
|
cbUrl = `https://logo.clearbit.com/${domain}`;
|
||||||
|
try {
|
||||||
|
cbResponse = await fetch(cbUrl);
|
||||||
|
} catch (err) {
|
||||||
|
// okay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileyUrl = `https://tiley.herokuapp.com/avatar/${hashedId}/${name[0]}.png`;
|
||||||
|
return cbUrl && cbResponse && cbResponse.status === 200 ? cbUrl : tileyUrl;
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
// @flow
|
||||||
|
import { generateAvatarUrl } from "./avatars";
|
||||||
|
|
||||||
|
it("should return clearbit url if available", async () => {
|
||||||
|
const url = await generateAvatarUrl({
|
||||||
|
id: "google",
|
||||||
|
domain: "google.com",
|
||||||
|
name: "Google",
|
||||||
|
});
|
||||||
|
expect(url).toBe("https://logo.clearbit.com/google.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return tiley url if clearbit unavailable", async () => {
|
||||||
|
const url = await generateAvatarUrl({
|
||||||
|
id: "invalid",
|
||||||
|
domain: "example.invalid",
|
||||||
|
name: "Invalid",
|
||||||
|
});
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://tiley.herokuapp.com/avatar/f1234d75178d892a133a410355a5a990cf75d2f33eba25d575943d4df632f3a4/I.png"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return tiley url if domain not provided", async () => {
|
||||||
|
const url = await generateAvatarUrl({
|
||||||
|
id: "google",
|
||||||
|
name: "Google",
|
||||||
|
});
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://tiley.herokuapp.com/avatar/bbdefa2950f49882f295b1285d4fa9dec45fc4144bfb07ee6acc68762d12c2e3/G.png"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return tiley url if name not provided", async () => {
|
||||||
|
const url = await generateAvatarUrl({
|
||||||
|
id: "google",
|
||||||
|
});
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://tiley.herokuapp.com/avatar/bbdefa2950f49882f295b1285d4fa9dec45fc4144bfb07ee6acc68762d12c2e3/U.png"
|
||||||
|
);
|
||||||
|
});
|
|
@ -0,0 +1,21 @@
|
||||||
|
// @flow
|
||||||
|
import { Team, AuthenticationProvider } from "../models";
|
||||||
|
|
||||||
|
export async function checkMigrations() {
|
||||||
|
if (process.env.DEPLOYMENT === "hosted") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = await Team.count();
|
||||||
|
const providers = await AuthenticationProvider.count();
|
||||||
|
|
||||||
|
if (teams && !providers) {
|
||||||
|
console.error(`
|
||||||
|
This version of Outline cannot start until a data migration is complete.
|
||||||
|
Backup your database, run the database migrations and the following script:
|
||||||
|
|
||||||
|
$ node ./build/server/scripts/20210226232041-migrate-authentication.js
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue