feat: Custom accent color (#4897)
* types * Working, but messy * Add InputColor component * types * Show default theme values when not customized * Support custom theme on team sign-in page * Payload validation * Custom theme on shared documents * Improve theme validation * Team -> Workspace in settings
This commit is contained in:
1
server/routes/api/teams/index.ts
Normal file
1
server/routes/api/teams/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./teams";
|
||||
55
server/routes/api/teams/schema.ts
Normal file
55
server/routes/api/teams/schema.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { z } from "zod";
|
||||
import { UserRole } from "@server/models/User";
|
||||
import BaseSchema from "@server/routes/api/BaseSchema";
|
||||
|
||||
export const TeamsUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Team name */
|
||||
name: z.string().optional(),
|
||||
/** Avatar URL */
|
||||
avatarUrl: z.string().optional(),
|
||||
/** The subdomain to access the team */
|
||||
subdomain: z.string().optional(),
|
||||
/** Whether public sharing is enabled */
|
||||
sharing: z.boolean().optional(),
|
||||
/** Whether siginin with email is enabled */
|
||||
guestSignin: z.boolean().optional(),
|
||||
/** Whether third-party document embeds are enabled */
|
||||
documentEmbeds: z.boolean().optional(),
|
||||
/** Whether team members are able to create new collections */
|
||||
memberCollectionCreate: z.boolean().optional(),
|
||||
/** Whether collaborative editing is enabled */
|
||||
collaborativeEditing: z.boolean().optional(),
|
||||
/** The default landing collection for the team */
|
||||
defaultCollectionId: z.string().uuid().nullish(),
|
||||
/** The default user role */
|
||||
defaultUserRole: z
|
||||
.string()
|
||||
.refine((val) => Object.values(UserRole).includes(val as UserRole))
|
||||
.optional(),
|
||||
/** Whether new users must be invited to join the team */
|
||||
inviteRequired: z.boolean().optional(),
|
||||
/** Domains allowed to sign-in with SSO */
|
||||
allowedDomains: z.array(z.string()).optional(),
|
||||
/** Team preferences */
|
||||
preferences: z
|
||||
.object({
|
||||
/** Whether documents have a separate edit mode instead of seamless editing. */
|
||||
seamlessEdit: z.boolean().optional(),
|
||||
/** Whether to use team logo across the app for branding. */
|
||||
publicBranding: z.boolean().optional(),
|
||||
/** Whether viewers should see download options. */
|
||||
viewersCanExport: z.boolean().optional(),
|
||||
/** The custom theme for the team. */
|
||||
customTheme: z
|
||||
.object({
|
||||
accent: z.string().min(4).max(7).regex(/^#/).optional(),
|
||||
accentText: z.string().min(4).max(7).regex(/^#/).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TeamsUpdateSchemaReq = z.infer<typeof TeamsUpdateSchema>;
|
||||
294
server/routes/api/teams/teams.test.ts
Normal file
294
server/routes/api/teams/teams.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import env from "@server/env";
|
||||
import { TeamDomain } from "@server/models";
|
||||
import { buildAdmin, buildCollection, buildTeam } from "@server/test/factories";
|
||||
import { seed, getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("teams.create", () => {
|
||||
it("creates a team", async () => {
|
||||
env.DEPLOYMENT = "hosted";
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
const res = await server.post("/api/teams.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
name: "new workspace",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.team.name).toEqual("new workspace");
|
||||
expect(body.data.team.subdomain).toEqual("new-workspace");
|
||||
});
|
||||
|
||||
it("requires a cloud hosted deployment", async () => {
|
||||
env.DEPLOYMENT = "";
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
const res = await server.post("/api/teams.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
name: "new workspace",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(402);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#team.update", () => {
|
||||
it("should update team details", async () => {
|
||||
const { admin } = await seed();
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
name: "New name",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual("New name");
|
||||
});
|
||||
|
||||
it("should add new allowed Domains, removing empty string values", async () => {
|
||||
const { admin, team } = await seed();
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
allowedDomains: [
|
||||
"example-company.com",
|
||||
"",
|
||||
"example-company.org",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.allowedDomains.sort()).toEqual([
|
||||
"example-company.com",
|
||||
"example-company.org",
|
||||
]);
|
||||
|
||||
const teamDomains: TeamDomain[] = await TeamDomain.findAll({
|
||||
where: { teamId: team.id },
|
||||
});
|
||||
expect(teamDomains.map((d) => d.name).sort()).toEqual([
|
||||
"example-company.com",
|
||||
"example-company.org",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should remove old allowed Domains", async () => {
|
||||
const { admin, team } = await seed();
|
||||
const existingTeamDomain = await TeamDomain.create({
|
||||
teamId: team.id,
|
||||
name: "example-company.com",
|
||||
createdById: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
allowedDomains: [],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.allowedDomains).toEqual([]);
|
||||
|
||||
const teamDomains: TeamDomain[] = await TeamDomain.findAll({
|
||||
where: { teamId: team.id },
|
||||
});
|
||||
expect(teamDomains.map((d) => d.name)).toEqual([]);
|
||||
|
||||
expect(await TeamDomain.findByPk(existingTeamDomain.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("should add new allowed domains and remove old ones", async () => {
|
||||
const { admin, team } = await seed();
|
||||
const existingTeamDomain = await TeamDomain.create({
|
||||
teamId: team.id,
|
||||
name: "example-company.com",
|
||||
createdById: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
allowedDomains: ["example-company.org", "example-company.net"],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.allowedDomains.sort()).toEqual([
|
||||
"example-company.net",
|
||||
"example-company.org",
|
||||
]);
|
||||
|
||||
const teamDomains: TeamDomain[] = await TeamDomain.findAll({
|
||||
where: { teamId: team.id },
|
||||
});
|
||||
expect(teamDomains.map((d) => d.name).sort()).toEqual(
|
||||
["example-company.org", "example-company.net"].sort()
|
||||
);
|
||||
|
||||
expect(await TeamDomain.findByPk(existingTeamDomain.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("should only allow member,viewer or admin as default role", async () => {
|
||||
const { admin } = await seed();
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
defaultUserRole: "New name",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
const successRes = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
defaultUserRole: "viewer",
|
||||
},
|
||||
});
|
||||
const body = await successRes.json();
|
||||
expect(successRes.status).toEqual(200);
|
||||
expect(body.data.defaultUserRole).toBe("viewer");
|
||||
});
|
||||
|
||||
it("should allow identical team details", async () => {
|
||||
const { admin, team } = await seed();
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
name: team.name,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual(team.name);
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
name: "New name",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
await seed();
|
||||
const res = await server.post("/api/team.update");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
|
||||
it("should update default collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
defaultCollectionId: collection.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.defaultCollectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should default to home if default collection is deleted", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
defaultCollectionId: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.defaultCollectionId).toEqual(collection.id);
|
||||
|
||||
const deleteRes = await server.post("/api/collections.delete", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: collection.id,
|
||||
},
|
||||
});
|
||||
expect(deleteRes.status).toEqual(200);
|
||||
|
||||
const res3 = await server.post("/api/auth.info", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body3 = await res3.json();
|
||||
expect(res3.status).toEqual(200);
|
||||
expect(body3.data.team.defaultCollectionId).toEqual(null);
|
||||
});
|
||||
|
||||
it("should update default collection to null when collection is made private", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
defaultCollectionId: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.defaultCollectionId).toEqual(collection.id);
|
||||
|
||||
const updateRes = await server.post("/api/collections.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: collection.id,
|
||||
permission: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateRes.status).toEqual(200);
|
||||
|
||||
const res3 = await server.post("/api/auth.info", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body3 = await res3.json();
|
||||
expect(res3.status).toEqual(200);
|
||||
expect(body3.data.team.defaultCollectionId).toEqual(null);
|
||||
});
|
||||
});
|
||||
122
server/routes/api/teams/teams.ts
Normal file
122
server/routes/api/teams/teams.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import invariant from "invariant";
|
||||
import Router from "koa-router";
|
||||
import teamCreator from "@server/commands/teamCreator";
|
||||
import teamUpdater from "@server/commands/teamUpdater";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Event, Team, TeamDomain, User } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentTeam, presentPolicies } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
"team.update",
|
||||
auth(),
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
validate(T.TeamsUpdateSchema),
|
||||
async (ctx: APIContext<T.TeamsUpdateSchemaReq>) => {
|
||||
const { user } = ctx.state.auth;
|
||||
const team = await Team.findByPk(user.teamId, {
|
||||
include: [{ model: TeamDomain }],
|
||||
});
|
||||
authorize(user, "update", team);
|
||||
|
||||
const updatedTeam = await teamUpdater({
|
||||
params: ctx.input.body,
|
||||
user,
|
||||
team,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentTeam(updatedTeam),
|
||||
policies: presentPolicies(user, [updatedTeam]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"teams.create",
|
||||
auth(),
|
||||
rateLimiter(RateLimiterStrategy.FivePerHour),
|
||||
async (ctx: APIContext) => {
|
||||
const { user } = ctx.state.auth;
|
||||
const { name } = ctx.request.body;
|
||||
|
||||
const existingTeam = await Team.scope(
|
||||
"withAuthenticationProviders"
|
||||
).findByPk(user.teamId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
authorize(user, "createTeam", existingTeam);
|
||||
|
||||
const authenticationProviders = existingTeam.authenticationProviders.map(
|
||||
(provider) => {
|
||||
return {
|
||||
name: provider.name,
|
||||
providerId: provider.providerId,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
invariant(
|
||||
authenticationProviders?.length,
|
||||
"Team must have at least one authentication provider"
|
||||
);
|
||||
|
||||
const [team, newUser] = await sequelize.transaction(async (transaction) => {
|
||||
const team = await teamCreator({
|
||||
name,
|
||||
subdomain: name,
|
||||
authenticationProviders,
|
||||
ip: ctx.ip,
|
||||
transaction,
|
||||
});
|
||||
|
||||
const newUser = await User.create(
|
||||
{
|
||||
teamId: team.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
isAdmin: true,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.create",
|
||||
actorId: user.id,
|
||||
userId: newUser.id,
|
||||
teamId: newUser.teamId,
|
||||
data: {
|
||||
name: newUser.name,
|
||||
},
|
||||
ip: ctx.ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
return [team, newUser];
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
data: {
|
||||
team: presentTeam(team),
|
||||
transferUrl: `${
|
||||
team.url
|
||||
}/auth/redirect?token=${newUser?.getTransferToken()}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user