mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e821856fa0 | |||
| 967f9a5537 |
@@ -94,6 +94,7 @@ yarn install
|
||||
## Database & ORM
|
||||
|
||||
- Use Sequelize models in `server/models/`.
|
||||
- Add class-level validations on model columns wherever possible (e.g. `@IsIn` for enum fields, `validate: { min, max }` for numeric ranges, `@Length` for string bounds). Prefer model validations over relying solely on route-level or migration-level constraints.
|
||||
- Generate migrations with Sequelize CLI:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
} from "outline-icons";
|
||||
import { FeatureFlag } from "@shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { createAction, createActionWithChildren } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
import history from "~/utils/history";
|
||||
@@ -206,23 +206,24 @@ export const toggleFeatureFlag = createActionWithChildren({
|
||||
icon: <BeakerIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
children: Object.values(Feature).map((flag) =>
|
||||
createAction({
|
||||
id: `flag-${flag}`,
|
||||
name: flag,
|
||||
selected: () => FeatureFlags.isEnabled(flag),
|
||||
section: DeveloperSection,
|
||||
perform: () => {
|
||||
if (FeatureFlags.isEnabled(flag)) {
|
||||
FeatureFlags.disable(flag);
|
||||
toast.success(`Disabled feature flag: ${flag}`);
|
||||
} else {
|
||||
FeatureFlags.enable(flag);
|
||||
toast.success(`Enabled feature flag: ${flag}`);
|
||||
}
|
||||
},
|
||||
})
|
||||
),
|
||||
children: ({ stores }) =>
|
||||
(Object.values(FeatureFlag) as unknown as FeatureFlag[]).map((flag) =>
|
||||
createAction({
|
||||
id: `flag-${flag}`,
|
||||
name: flag,
|
||||
selected: () => stores.auth.getFeatureFlag(flag),
|
||||
section: DeveloperSection,
|
||||
perform: () => {
|
||||
stores.auth.toggleFeatureFlagOverride(flag);
|
||||
const enabled = stores.auth.getFeatureFlag(flag);
|
||||
toast.success(
|
||||
enabled
|
||||
? `Enabled feature flag: ${flag}`
|
||||
: `Disabled feature flag: ${flag}`
|
||||
);
|
||||
},
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const developer = createActionWithChildren({
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { FeatureFlag } from "@shared/types";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
/**
|
||||
* Hook to check if a feature flag is enabled for the current team.
|
||||
*
|
||||
* @param flag The feature flag to check.
|
||||
* @returns Whether the flag is enabled.
|
||||
*/
|
||||
export default function useFeatureFlag(flag: FeatureFlag): boolean {
|
||||
const { auth } = useStores();
|
||||
return auth.getFeatureFlag(flag);
|
||||
}
|
||||
+57
-2
@@ -3,7 +3,8 @@ import invariant from "invariant";
|
||||
import isNil from "lodash/isNil";
|
||||
import { observable, action, computed, autorun, runInAction } from "mobx";
|
||||
import { getCookie, setCookie } from "tiny-cookie";
|
||||
import type { CustomTheme } from "@shared/types";
|
||||
import { FeatureFlagDefaults } from "@shared/constants";
|
||||
import type { CustomTheme, FeatureFlag, FeatureFlags } from "@shared/types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||||
import type RootStore from "~/stores/RootStore";
|
||||
@@ -19,7 +20,12 @@ import Store from "./base/Store";
|
||||
|
||||
type PersistedData = Pick<
|
||||
AuthStore,
|
||||
"user" | "team" | "collaborationToken" | "availableTeams" | "policies"
|
||||
| "user"
|
||||
| "team"
|
||||
| "collaborationToken"
|
||||
| "availableTeams"
|
||||
| "policies"
|
||||
| "featureFlags"
|
||||
>;
|
||||
|
||||
type Provider = {
|
||||
@@ -55,6 +61,14 @@ export default class AuthStore extends Store<Team> {
|
||||
@observable
|
||||
public logoutRedirectUri?: string;
|
||||
|
||||
/* The resolved feature flags for the current team. */
|
||||
@observable
|
||||
public featureFlags: Required<FeatureFlags> = { ...FeatureFlagDefaults };
|
||||
|
||||
/* Local overrides for feature flags, used for development testing. */
|
||||
@observable
|
||||
private featureFlagOverrides: Partial<FeatureFlags> = {};
|
||||
|
||||
/* A list of teams that the current user has access to. */
|
||||
@observable
|
||||
public availableTeams?: {
|
||||
@@ -143,6 +157,7 @@ export default class AuthStore extends Store<Team> {
|
||||
this.currentTeamId = data.team?.id;
|
||||
this.currentUserId = data.user?.id;
|
||||
this.collaborationToken = data.collaborationToken;
|
||||
this.featureFlags = data.featureFlags ?? { ...FeatureFlagDefaults };
|
||||
this.lastSignedIn = getCookie("lastSignedIn");
|
||||
}
|
||||
|
||||
@@ -169,6 +184,44 @@ export default class AuthStore extends Store<Team> {
|
||||
return policy ? [policy] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a feature flag is enabled for the current team.
|
||||
* Local overrides take precedence over server-resolved values.
|
||||
*
|
||||
* @param flag The feature flag to check.
|
||||
* @returns Whether the flag is enabled.
|
||||
*/
|
||||
getFeatureFlag(flag: FeatureFlag): boolean {
|
||||
const overrides = this.featureFlagOverrides as Record<string, boolean>;
|
||||
if (flag in overrides) {
|
||||
return overrides[flag];
|
||||
}
|
||||
const flags = this.featureFlags as Record<string, boolean>;
|
||||
const defaults = FeatureFlagDefaults as Record<string, boolean>;
|
||||
return flags[flag] ?? defaults[flag] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a local override for a feature flag. Used for development testing.
|
||||
*
|
||||
* @param flag The feature flag to toggle.
|
||||
*/
|
||||
@action
|
||||
toggleFeatureFlagOverride(flag: FeatureFlag) {
|
||||
const current = this.getFeatureFlag(flag);
|
||||
(this.featureFlagOverrides as Record<string, boolean>)[flag] = !current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a feature flag has a local override set.
|
||||
*
|
||||
* @param flag The feature flag to check.
|
||||
* @returns Whether a local override exists.
|
||||
*/
|
||||
hasFeatureFlagOverride(flag: FeatureFlag): boolean {
|
||||
return flag in (this.featureFlagOverrides as Record<string, boolean>);
|
||||
}
|
||||
|
||||
/** Whether the user is signed in */
|
||||
@computed
|
||||
get authenticated(): boolean {
|
||||
@@ -182,6 +235,7 @@ export default class AuthStore extends Store<Team> {
|
||||
team: this.team,
|
||||
collaborationToken: this.collaborationToken,
|
||||
availableTeams: this.availableTeams,
|
||||
featureFlags: this.featureFlags,
|
||||
policies: this.policies,
|
||||
};
|
||||
}
|
||||
@@ -215,6 +269,7 @@ export default class AuthStore extends Store<Team> {
|
||||
|
||||
this.availableTeams = res.data.availableTeams;
|
||||
this.collaborationToken = res.data.collaborationToken;
|
||||
this.featureFlags = data.featureFlags ?? { ...FeatureFlagDefaults };
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.configureScope((scope) => {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { observable } from "mobx";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
|
||||
export enum Feature {
|
||||
/** New collection permissions UI */
|
||||
newCollectionSharing = "newCollectionSharing",
|
||||
}
|
||||
|
||||
/** Default values for feature flags */
|
||||
const FeatureDefaults: Record<Feature, boolean> = {
|
||||
[Feature.newCollectionSharing]: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* A simple feature flagging system that stores flags in browser storage.
|
||||
*/
|
||||
export class FeatureFlags {
|
||||
public static isEnabled(flag: Feature) {
|
||||
// init on first read
|
||||
if (this.initalized === false) {
|
||||
this.cache = new Set();
|
||||
for (const key of Object.values(Feature)) {
|
||||
const value = Storage.get(key);
|
||||
if (value === true) {
|
||||
this.cache.add(key);
|
||||
}
|
||||
}
|
||||
this.initalized = true;
|
||||
}
|
||||
|
||||
return this.cache.has(flag) ? true : (FeatureDefaults[flag] ?? false);
|
||||
}
|
||||
|
||||
public static enable(flag: Feature) {
|
||||
this.cache.add(flag);
|
||||
Storage.set(flag, true);
|
||||
}
|
||||
|
||||
public static disable(flag: Feature) {
|
||||
this.cache.delete(flag);
|
||||
Storage.set(flag, false);
|
||||
}
|
||||
|
||||
@observable
|
||||
private static cache: Set<Feature> = new Set();
|
||||
|
||||
private static initalized = false;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Next } from "koa";
|
||||
import type { FeatureFlag } from "@shared/types";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { FeatureFlag as FeatureFlagModel } from "@server/models";
|
||||
import type { APIContext } from "@server/types";
|
||||
|
||||
/**
|
||||
* Middleware to check if a feature flag is enabled for the team.
|
||||
*
|
||||
* @param flag The feature flag to check.
|
||||
* @returns The middleware function.
|
||||
*/
|
||||
export function featureFlag(flag: FeatureFlag) {
|
||||
return async function featureFlagMiddleware(ctx: APIContext, next: Next) {
|
||||
const { user } = ctx.state.auth;
|
||||
const enabled = await FeatureFlagModel.isEnabled(flag, user.teamId);
|
||||
if (!enabled) {
|
||||
throw ValidationError("This feature is not currently available");
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable("feature_flags", {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
teamIds: {
|
||||
type: Sequelize.ARRAY(Sequelize.UUID),
|
||||
allowNull: false,
|
||||
defaultValue: [],
|
||||
},
|
||||
percentage: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
down: async (queryInterface) => {
|
||||
await queryInterface.dropTable("feature_flags");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { FeatureFlagDefaults } from "@shared/constants";
|
||||
import { FeatureFlag as FeatureFlagEnum } from "@shared/types";
|
||||
import { FeatureFlag } from "@server/models";
|
||||
import { buildTeam } from "@server/test/factories";
|
||||
|
||||
describe("FeatureFlag", () => {
|
||||
afterEach(async () => {
|
||||
await FeatureFlag.destroy({ where: {}, force: true });
|
||||
await FeatureFlag.invalidateCache();
|
||||
});
|
||||
|
||||
describe("isInPercentage", () => {
|
||||
it("should return false when percentage is 0", () => {
|
||||
const teamId = randomUUID();
|
||||
expect(FeatureFlag.isInPercentage(teamId, "testFlag", 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when percentage is 100", () => {
|
||||
const teamId = randomUUID();
|
||||
expect(FeatureFlag.isInPercentage(teamId, "testFlag", 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("should be deterministic for the same inputs", () => {
|
||||
const teamId = randomUUID();
|
||||
const a = FeatureFlag.isInPercentage(teamId, "testFlag", 50);
|
||||
const b = FeatureFlag.isInPercentage(teamId, "testFlag", 50);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("should vary by teamId", () => {
|
||||
const results = new Set<boolean>();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
results.add(FeatureFlag.isInPercentage(randomUUID(), "testFlag", 50));
|
||||
}
|
||||
expect(results.size).toBe(2);
|
||||
});
|
||||
|
||||
it("should vary by flag name", () => {
|
||||
const teamId = randomUUID();
|
||||
const results = new Set<boolean>();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
results.add(FeatureFlag.isInPercentage(teamId, `flag-${i}`, 50));
|
||||
}
|
||||
expect(results.size).toBe(2);
|
||||
});
|
||||
|
||||
it("should be monotonic — increasing percentage never removes a team", () => {
|
||||
const teamId = randomUUID();
|
||||
let becameTrue = false;
|
||||
|
||||
for (let pct = 0; pct <= 100; pct++) {
|
||||
const result = FeatureFlag.isInPercentage(teamId, "testFlag", pct);
|
||||
if (result) {
|
||||
becameTrue = true;
|
||||
}
|
||||
if (becameTrue) {
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should approximate the target percentage across many teams", () => {
|
||||
const total = 1000;
|
||||
let enabled = 0;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (FeatureFlag.isInPercentage(randomUUID(), "testFlag", 30)) {
|
||||
enabled++;
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = enabled / total;
|
||||
expect(ratio).toBeGreaterThan(0.2);
|
||||
expect(ratio).toBeLessThan(0.4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAll", () => {
|
||||
it("should return defaults when no rows exist", async () => {
|
||||
const team = await buildTeam();
|
||||
const result = await FeatureFlag.resolveAll(team.id);
|
||||
expect(result).toEqual(FeatureFlagDefaults);
|
||||
});
|
||||
|
||||
it("should return true when team is in teamIds", async () => {
|
||||
const team = await buildTeam();
|
||||
await FeatureFlag.create({
|
||||
name: FeatureFlagEnum.GroupSync,
|
||||
teamIds: [team.id],
|
||||
percentage: null,
|
||||
});
|
||||
const result = await FeatureFlag.resolveAll(team.id);
|
||||
expect(result[FeatureFlagEnum.GroupSync]).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when row exists but team not included", async () => {
|
||||
const team = await buildTeam();
|
||||
const otherTeam = await buildTeam();
|
||||
await FeatureFlag.create({
|
||||
name: FeatureFlagEnum.GroupSync,
|
||||
teamIds: [otherTeam.id],
|
||||
percentage: null,
|
||||
});
|
||||
const result = await FeatureFlag.resolveAll(team.id);
|
||||
expect(result[FeatureFlagEnum.GroupSync]).toBe(false);
|
||||
});
|
||||
|
||||
it("should use percentage when team is not in teamIds", async () => {
|
||||
const team = await buildTeam();
|
||||
await FeatureFlag.create({
|
||||
name: FeatureFlagEnum.GroupSync,
|
||||
teamIds: [],
|
||||
percentage: 100,
|
||||
});
|
||||
const result = await FeatureFlag.resolveAll(team.id);
|
||||
expect(result[FeatureFlagEnum.GroupSync]).toBe(true);
|
||||
});
|
||||
|
||||
it("should enable for team in teamIds regardless of percentage", async () => {
|
||||
const team = await buildTeam();
|
||||
await FeatureFlag.create({
|
||||
name: FeatureFlagEnum.GroupSync,
|
||||
teamIds: [team.id],
|
||||
percentage: 0,
|
||||
});
|
||||
const result = await FeatureFlag.resolveAll(team.id);
|
||||
expect(result[FeatureFlagEnum.GroupSync]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEnabled", () => {
|
||||
it("should return default when no row exists", async () => {
|
||||
const team = await buildTeam();
|
||||
const result = await FeatureFlag.isEnabled(
|
||||
FeatureFlagEnum.GroupSync,
|
||||
team.id
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when team is explicitly enabled", async () => {
|
||||
const team = await buildTeam();
|
||||
await FeatureFlag.create({
|
||||
name: FeatureFlagEnum.GroupSync,
|
||||
teamIds: [team.id],
|
||||
percentage: null,
|
||||
});
|
||||
const result = await FeatureFlag.isEnabled(
|
||||
FeatureFlagEnum.GroupSync,
|
||||
team.id
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import {
|
||||
Table,
|
||||
Column,
|
||||
DataType,
|
||||
AllowNull,
|
||||
Default,
|
||||
IsIn,
|
||||
Unique,
|
||||
AfterCreate,
|
||||
AfterUpdate,
|
||||
AfterDestroy,
|
||||
AfterBulkCreate,
|
||||
AfterBulkUpdate,
|
||||
AfterBulkDestroy,
|
||||
} from "sequelize-typescript";
|
||||
import { FeatureFlagDefaults } from "@shared/constants";
|
||||
import {
|
||||
FeatureFlag as FeatureFlagEnum,
|
||||
type FeatureFlags,
|
||||
} from "@shared/types";
|
||||
import { Hour } from "@shared/utils/time";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
@Table({ tableName: "feature_flags", modelName: "feature_flag" })
|
||||
@Fix
|
||||
class FeatureFlag extends IdModel<
|
||||
InferAttributes<FeatureFlag>,
|
||||
Partial<InferCreationAttributes<FeatureFlag>>
|
||||
> {
|
||||
/** The flag name from the FeatureFlag enum. */
|
||||
@IsIn([Object.values(FeatureFlagEnum)])
|
||||
@Unique
|
||||
@Column(DataType.STRING)
|
||||
name: FeatureFlagEnum;
|
||||
|
||||
/** Team IDs that have this flag explicitly enabled. */
|
||||
@Default([])
|
||||
@Column(DataType.ARRAY(DataType.UUID))
|
||||
teamIds: string[];
|
||||
|
||||
/** Percentage of teams to enable for (0-100). Applies to teams not in teamIds. */
|
||||
@AllowNull
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
})
|
||||
percentage: number | null;
|
||||
|
||||
// Hooks
|
||||
|
||||
@AfterCreate
|
||||
@AfterUpdate
|
||||
@AfterDestroy
|
||||
@AfterBulkCreate
|
||||
@AfterBulkUpdate
|
||||
@AfterBulkDestroy
|
||||
static async invalidateCache() {
|
||||
await CacheHelper.clearData(RedisPrefixHelper.getFeatureFlagsKey());
|
||||
}
|
||||
|
||||
// Static methods
|
||||
|
||||
/**
|
||||
* Resolve all feature flags for a team.
|
||||
*
|
||||
* @param teamId The team to resolve flags for.
|
||||
* @returns A complete map of all flags with resolved boolean values.
|
||||
*/
|
||||
static async resolveAll(teamId: string): Promise<Required<FeatureFlags>> {
|
||||
const rows = await CacheHelper.getDataOrSet(
|
||||
RedisPrefixHelper.getFeatureFlagsKey(),
|
||||
async () => this.findAll(),
|
||||
12 * Hour.seconds
|
||||
);
|
||||
|
||||
const rowsByName = new Map((rows ?? []).map((r) => [r.name, r]));
|
||||
const resolved = Object.assign(
|
||||
{} as Record<string, boolean>,
|
||||
FeatureFlagDefaults
|
||||
);
|
||||
|
||||
for (const flag of Object.values(FeatureFlagEnum) as string[]) {
|
||||
const row = rowsByName.get(flag as unknown as FeatureFlagEnum);
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.teamIds.includes(teamId)) {
|
||||
resolved[flag] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.percentage !== null) {
|
||||
resolved[flag] = this.isInPercentage(teamId, flag, row.percentage);
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[flag] = false;
|
||||
}
|
||||
|
||||
return resolved as Required<FeatureFlags>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a single flag is enabled for a team.
|
||||
*
|
||||
* @param flag The feature flag to check.
|
||||
* @param teamId The team to check for.
|
||||
* @returns Whether the flag is enabled.
|
||||
*/
|
||||
static async isEnabled(
|
||||
flag: FeatureFlagEnum,
|
||||
teamId: string
|
||||
): Promise<boolean> {
|
||||
const resolved = await this.resolveAll(teamId);
|
||||
return (resolved as Record<string, boolean>)[flag] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic percentage check. Uses MD5 hash of teamId + flag name
|
||||
* so the same team always gets the same result for a given percentage.
|
||||
* As percentage increases, previously-included teams stay included.
|
||||
*
|
||||
* @param teamId The team ID.
|
||||
* @param flag The flag name.
|
||||
* @param percentage The rollout percentage (0-100).
|
||||
* @returns Whether this team falls within the percentage.
|
||||
*/
|
||||
static isInPercentage(
|
||||
teamId: string,
|
||||
flag: string,
|
||||
percentage: number
|
||||
): boolean {
|
||||
const hash = crypto.createHash("md5").update(`${teamId}:${flag}`).digest();
|
||||
const value = hash.readUInt32BE(0) % 100;
|
||||
return value < percentage;
|
||||
}
|
||||
}
|
||||
|
||||
export default FeatureFlag;
|
||||
@@ -18,6 +18,8 @@ export { default as Document } from "./Document";
|
||||
|
||||
export { default as Event } from "./Event";
|
||||
|
||||
export { default as FeatureFlag } from "./FeatureFlag";
|
||||
|
||||
export { default as ExternalGroup } from "./ExternalGroup";
|
||||
|
||||
export { default as FileOperation } from "./FileOperation";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { parseDomain } from "@shared/utils/domains";
|
||||
import env from "@server/env";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import { Event, Team } from "@server/models";
|
||||
import { Event, FeatureFlag, Team } from "@server/models";
|
||||
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
|
||||
import {
|
||||
presentUser,
|
||||
@@ -122,18 +122,20 @@ router.post("auth.info", auth(), async (ctx: APIContext<T.AuthInfoReq>) => {
|
||||
const sessions = getSessionsInCookie(ctx);
|
||||
const signedInTeamIds = Object.keys(sessions);
|
||||
|
||||
const [team, groups, signedInTeams, availableTeams] = await Promise.all([
|
||||
Team.scope("withDomains").findByPk(user.teamId, {
|
||||
rejectOnEmpty: true,
|
||||
}),
|
||||
user.groups(),
|
||||
Team.findAll({
|
||||
where: {
|
||||
id: signedInTeamIds,
|
||||
},
|
||||
}),
|
||||
user.availableTeams(),
|
||||
]);
|
||||
const [team, groups, featureFlags, signedInTeams, availableTeams] =
|
||||
await Promise.all([
|
||||
Team.scope("withDomains").findByPk(user.teamId, {
|
||||
rejectOnEmpty: true,
|
||||
}),
|
||||
user.groups(),
|
||||
FeatureFlag.resolveAll(user.teamId),
|
||||
Team.findAll({
|
||||
where: {
|
||||
id: signedInTeamIds,
|
||||
},
|
||||
}),
|
||||
user.availableTeams(),
|
||||
]);
|
||||
|
||||
// If the user did not _just_ sign in then we need to check if they continue
|
||||
// to have access to the workspace they are signed into. This only applies
|
||||
@@ -165,6 +167,7 @@ router.post("auth.info", auth(), async (ctx: APIContext<T.AuthInfoReq>) => {
|
||||
includeDetails: true,
|
||||
}),
|
||||
team: presentTeam(team),
|
||||
featureFlags,
|
||||
groups: await Promise.all(groups.map(presentGroup)),
|
||||
groupUsers: groups.map((group) => presentGroupUser(group.groupUsers[0])),
|
||||
collaborationToken: user.getCollaborationToken(),
|
||||
|
||||
@@ -42,4 +42,13 @@ export class RedisPrefixHelper {
|
||||
public static getUserCollectionIdsKey(userId: string) {
|
||||
return `uc:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets key for caching all feature flag rows.
|
||||
*
|
||||
* @returns the cache key string.
|
||||
*/
|
||||
public static getFeatureFlagsKey() {
|
||||
return "featureFlags:all";
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
import type { TeamPreferences, UserPreferences } from "./types";
|
||||
import type { FeatureFlags, TeamPreferences, UserPreferences } from "./types";
|
||||
import {
|
||||
FeatureFlag,
|
||||
TOCPosition,
|
||||
TeamPreference,
|
||||
UserPreference,
|
||||
@@ -22,6 +23,10 @@ export const CSRF = {
|
||||
fieldName: "_csrf",
|
||||
};
|
||||
|
||||
export const FeatureFlagDefaults: Required<FeatureFlags> = {
|
||||
[FeatureFlag.GroupSync]: false,
|
||||
};
|
||||
|
||||
export const TeamPreferenceDefaults: TeamPreferences = {
|
||||
[TeamPreference.SeamlessEdit]: true,
|
||||
[TeamPreference.ViewersCanExport]: true,
|
||||
|
||||
@@ -431,6 +431,15 @@ export type TeamPreferences = {
|
||||
[TeamPreference.DisabledEmbeds]?: string[];
|
||||
};
|
||||
|
||||
export enum FeatureFlag {
|
||||
/** Whether group sync from external providers is enabled. */
|
||||
GroupSync = "groupSync",
|
||||
}
|
||||
|
||||
export type FeatureFlags = {
|
||||
[K in FeatureFlag]?: boolean;
|
||||
};
|
||||
|
||||
export enum NavigationNodeType {
|
||||
Collection = "collection",
|
||||
Document = "document",
|
||||
|
||||
Reference in New Issue
Block a user